fifa17-recon: take running-backend versions of 8 runtime files (direction fix)
The earlier reconcile committed the local working-tree versions of these files, which are OLDER than the deployed backend. The running container (C) is byte-identical to docker/fifa17-python/tools (B) and is a strict superset: it adds profile_path_for/select_account/ensure_security_question (fut_store), safe_header_for_log/safe_request_path/security_question_route (utas_server), account_sync_route/_match_call/match_ready_body, plus POW balance fields and match lifecycle support, with zero unique local functions lost. Reconciled tree is now a strict superset of B with every shared file byte-identical; verified via md5 map (0 missing, 0 differing).
This commit is contained in:
@@ -128,14 +128,52 @@ CLIENT_ID = ACCOUNT.CLIENT_ID
|
||||
PLATFORM = ACCOUNT.PLATFORM
|
||||
SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n"
|
||||
|
||||
# ================================================================== config
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
def refresh_account_identity():
|
||||
"""Refresh launcher-selected identity before constructing a Blaze session.
|
||||
|
||||
The account sync endpoint runs in the separate UTAS process and atomically
|
||||
replaces the shared active-account file. Blaze snapshots these aliases for
|
||||
its response builders, so refresh them once at each new TCP session.
|
||||
"""
|
||||
global PERSONA_ID, PERSONA_NAME, USER_ID, EXT_ID, EMAIL, ACCOUNT_LOCALE_FALLBACK
|
||||
ACCOUNT.load(force=True)
|
||||
PERSONA_ID = ACCOUNT.persona_id
|
||||
PERSONA_NAME = ACCOUNT.persona_name
|
||||
USER_ID = ACCOUNT.user_id
|
||||
EXT_ID = ACCOUNT.ext_id
|
||||
EMAIL = ACCOUNT.email
|
||||
ACCOUNT_LOCALE_FALLBACK = ACCOUNT.account_locale_int
|
||||
|
||||
# ================================================================== config
|
||||
#
|
||||
# Client/server split support (OpenFUT dev-container): two env vars, both
|
||||
# defaulting to loopback so the original all-on-localhost flow is byte-identical.
|
||||
# OPENFUT_BIND — the address the listeners bind (0.0.0.0 in a container).
|
||||
# OPENFUT_ADVERTISE — the address this server hands back to the client for the
|
||||
# NEXT hop (Blaze host, roster/UTAS/telemetry/QoS URLs). On
|
||||
# 105-local this is 127.0.0.1; on the 120 server it is the
|
||||
# server's LAN IP so the game dials 120 directly after the
|
||||
# first (hook/DNAT-redirected) contact.
|
||||
import os as _os_cfg
|
||||
_ADVERTISE = _os_cfg.environ.get("OPENFUT_ADVERTISE", "127.0.0.1")
|
||||
_BIND = _os_cfg.environ.get("OPENFUT_BIND", "127.0.0.1")
|
||||
|
||||
def _ip_str_to_u32(ip):
|
||||
"""Dotted-quad -> big-endian u32 (matches the original (127<<24)|1 layout).
|
||||
Falls back to loopback if the advertise value isn't a bare IPv4 literal."""
|
||||
try:
|
||||
a, b, c, d = (int(x) for x in ip.split("."))
|
||||
return (a << 24) | (b << 16) | (c << 8) | d
|
||||
except Exception:
|
||||
return (127 << 24) | 1
|
||||
|
||||
HOST = _BIND
|
||||
REDIR_PORT = 42127
|
||||
BLAZE_PORT = 42130
|
||||
NUCLEUS_PORT = 42131
|
||||
BLAZE_IP_STR = "127.0.0.1"
|
||||
BLAZE_IP_U32 = (127 << 24) | 1
|
||||
BLAZE_IP_STR = _ADVERTISE
|
||||
BLAZE_IP_U32 = _ip_str_to_u32(_ADVERTISE)
|
||||
LOG = "/tmp/blaze_responder.log"
|
||||
RXDIR = "/tmp/blaze_rx"
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -157,7 +195,9 @@ REPLY_EMPTY_TO_UNKNOWN = True
|
||||
# (grid-blaze order) or after (pamplona order). Both are reported to work.
|
||||
NOTIFY_BEFORE_LOGIN_REPLY = False
|
||||
|
||||
DUMP_FRAMES = True
|
||||
# Raw Fire2 frames and decoded TDF can contain auth/session material. Keep the
|
||||
# reverse-engineering capture path, but require an explicit opt-in for it.
|
||||
DUMP_FRAMES = os.environ.get("OPENFUT_BLAZE_DUMP_FRAMES") == "1"
|
||||
|
||||
_log_lock = threading.Lock()
|
||||
|
||||
@@ -525,7 +565,7 @@ OSDK_TICKER = []
|
||||
# branch does NOT wrap the value ("https://%s" is only the ini path) -> ABSOLUTE url.
|
||||
# Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject
|
||||
# http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK.
|
||||
ROSTER_HOST = "127.0.0.1:8081"
|
||||
ROSTER_HOST = "%s:8081" % _ADVERTISE
|
||||
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
|
||||
OSDK_ROSTER = [
|
||||
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST),
|
||||
@@ -603,7 +643,7 @@ CLIENT_CONFIGS = {
|
||||
# /etc/hosts easw.easports.com->127.0.0.1 redirect. MUST be exactly "http://127.0.0.1:8099/"
|
||||
# (scheme + trailing slash mandatory on the auth path). Do NOT serve FUT_TARGET_PORT
|
||||
# (bug @0x1801808e8 reads FUT_MAX_HOPS instead) nor FUT/MODULE_BASEURL_* (dead code).
|
||||
UTAS_BASE = "http://127.0.0.1:8099/"
|
||||
UTAS_BASE = "http://%s:8099/" % _ADVERTISE
|
||||
FUT_RS4_MODULES = [
|
||||
"AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD",
|
||||
"DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER",
|
||||
@@ -739,10 +779,12 @@ FUT_RS4_CONFIG = (
|
||||
|
||||
|
||||
def client_config_for(cfid: str) -> list:
|
||||
"""-> sorted [(key, value)]. Unknown CFID -> [] (an EMPTY MAP, which we
|
||||
still wrap in a present CONF field -- never an empty frame).
|
||||
FUT_RS4_* base-URL keys ride on EVERY CFID (merged '_all' store; which section
|
||||
CardsDLL reads is unproven, so serve them everywhere)."""
|
||||
"""Return sorted config rows for one section.
|
||||
|
||||
Unknown CFIDs still receive the shared FUT/content/POW rows because those
|
||||
consumers read the merged ``_all`` store and the contributing section is
|
||||
unproven. The response always carries a present CONF field.
|
||||
"""
|
||||
# OSDK_POW rides on EVERY CFID for the same reason FUT_RS4_* does: powdll's
|
||||
# FUN_18005a460 reads FIFA_POW_URL out of the merged '_all' store, and which
|
||||
# section it happens to read is unproven. Empty list when FUT_POW is unset, so
|
||||
@@ -774,7 +816,7 @@ def qos_config() -> "OrderedDict":
|
||||
has NO SVID, unlike Mirror's Edge Catalyst)."""
|
||||
return OrderedDict([
|
||||
("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo
|
||||
("PSA", (STRING, "127.0.0.1")),
|
||||
("PSA", (STRING, _ADVERTISE)),
|
||||
("PSP", (INT, 17502)),
|
||||
]))),
|
||||
("LNP", (INT, 10)),
|
||||
@@ -1100,7 +1142,7 @@ def post_auth_response_fields(sess: Session) -> "OrderedDict":
|
||||
client to have a well-formed config and then fail to connect quietly rather
|
||||
than resolve a real EA hostname."""
|
||||
tele = OrderedDict([ # GetTelemetryServerResponse (15)
|
||||
("ADRS", (STRING, "127.0.0.1")),
|
||||
("ADRS", (STRING, _ADVERTISE)),
|
||||
("ANON", (INT, 0)),
|
||||
("DISA", (STRING, "")),
|
||||
("EDCT", (INT, 0)),
|
||||
@@ -1117,7 +1159,7 @@ def post_auth_response_fields(sess: Session) -> "OrderedDict":
|
||||
("SVNM", (STRING, "telemetry-openfut")),
|
||||
])
|
||||
tick = OrderedDict([ # GetTickerServerResponse (3)
|
||||
("ADRS", (STRING, "127.0.0.1")),
|
||||
("ADRS", (STRING, _ADVERTISE)),
|
||||
("PORT", (INT, 8999)),
|
||||
("SKEY", (STRING, "")),
|
||||
])
|
||||
@@ -1261,8 +1303,10 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
|
||||
log(" -- client locale 0x%08x captured for ALOC" % loc)
|
||||
resp = preauth_response_fields(service_name=sess.service_name)
|
||||
payload = encode_tdf(resp)
|
||||
log(" -> PreAuthResponse (INST=%r, %d payload bytes):\n%s"
|
||||
% (sess.service_name, len(payload), heat2.dump(resp)))
|
||||
log(" -> PreAuthResponse (INST=%r, %d payload bytes)"
|
||||
% (sess.service_name, len(payload)))
|
||||
if DUMP_FRAMES:
|
||||
log(" -> PreAuthResponse TDF:\n%s" % heat2.dump(resp))
|
||||
return [reply_to(hdr, payload)]
|
||||
|
||||
if cmd == CMD_PING:
|
||||
@@ -1276,8 +1320,9 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
|
||||
n = len(resp["CONF"][1][2])
|
||||
log(" -> FetchConfigResponse CFID=%r -> %d key(s)%s"
|
||||
% (cfid, n, "" if n else " (EMPTY MAP, unknown CFID)"))
|
||||
for k, v in resp["CONF"][1][2]:
|
||||
log(" %-32s = %s" % (k, v))
|
||||
if DUMP_FRAMES:
|
||||
for k, v in resp["CONF"][1][2]:
|
||||
log(" %-32s = %s" % (k, v))
|
||||
return [reply_to(hdr, encode_tdf(resp))]
|
||||
|
||||
if cmd == CMD_POSTAUTH:
|
||||
@@ -1311,12 +1356,13 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
|
||||
sess.auth_code = get_str(fields or {}, "AUTH", "")
|
||||
sess.logged_in = True
|
||||
sess.login_time = int(time.time())
|
||||
log(" == Authentication::login AUTH=%r (accepted WITHOUT Nucleus "
|
||||
"validation -- forged offline session)" % sess.auth_code)
|
||||
log(" == Authentication::login AUTH=[REDACTED] "
|
||||
"(accepted as an offline OpenFUT session)")
|
||||
resp = login_response_fields(sess)
|
||||
payload = encode_tdf(resp)
|
||||
log(" -> LoginResponse (%d bytes):\n%s"
|
||||
% (len(payload), heat2.dump(resp)))
|
||||
log(" -> LoginResponse (%d bytes)" % len(payload))
|
||||
if DUMP_FRAMES:
|
||||
log(" -> LoginResponse TDF:\n%s" % heat2.dump(resp))
|
||||
notifs = build_login_notifications(sess, sess.login_time)
|
||||
out = []
|
||||
if NOTIFY_BEFORE_LOGIN_REPLY:
|
||||
@@ -1467,9 +1513,10 @@ _frame_counter = [0]
|
||||
|
||||
|
||||
def blaze_handle(raw: socket.socket, addr) -> None:
|
||||
refresh_account_identity()
|
||||
log("*** BLAZE CONNECT from %s ***" % (addr,))
|
||||
sess = Session()
|
||||
log(" session key minted: %s" % sess.session_key)
|
||||
log(" session key minted: [REDACTED]")
|
||||
buf = bytearray()
|
||||
raw.settimeout(300)
|
||||
try:
|
||||
@@ -1500,10 +1547,10 @@ def blaze_handle(raw: socket.socket, addr) -> None:
|
||||
MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]),
|
||||
hdr["msg_num"], hdr["user_index"], hdr["options"],
|
||||
hdr["metadata_len"], hdr["payload_len"]))
|
||||
log("RX #%d HEX:\n%s" % (n, hexdump(frame)))
|
||||
if metadata:
|
||||
log("RX #%d METADATA:\n%s" % (n, hexdump(metadata)))
|
||||
if DUMP_FRAMES:
|
||||
log("RX #%d HEX:\n%s" % (n, hexdump(frame)))
|
||||
if metadata:
|
||||
log("RX #%d METADATA:\n%s" % (n, hexdump(metadata)))
|
||||
try:
|
||||
os.makedirs(RXDIR, exist_ok=True)
|
||||
fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin"
|
||||
@@ -1518,7 +1565,8 @@ def blaze_handle(raw: socket.socket, addr) -> None:
|
||||
if payload:
|
||||
try:
|
||||
fields = decode_tdf(payload)
|
||||
log("RX #%d TDF:\n%s" % (n, heat2.dump(fields)))
|
||||
if DUMP_FRAMES:
|
||||
log("RX #%d TDF:\n%s" % (n, heat2.dump(fields)))
|
||||
except Exception as e:
|
||||
log("RX #%d TDF DECODE FAILED: %s" % (n, e))
|
||||
else:
|
||||
@@ -1539,7 +1587,8 @@ def blaze_handle(raw: socket.socket, addr) -> None:
|
||||
ohdr["msg_type"]),
|
||||
MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]),
|
||||
ohdr["msg_num"], len(out), ohdr["payload_len"]))
|
||||
log("TX #%d.%d HEX:\n%s" % (n, k, hexdump(out, limit=1024)))
|
||||
if DUMP_FRAMES:
|
||||
log("TX #%d.%d HEX:\n%s" % (n, k, hexdump(out, limit=1024)))
|
||||
except ConnectionResetError:
|
||||
log("BLAZE %s: connection reset by client" % (addr,))
|
||||
except Exception as e:
|
||||
@@ -1637,6 +1686,10 @@ def redir_handle(raw: socket.socket, addr) -> None:
|
||||
# client can never reach accounts.ea.com. Note the exact spacing in the JSON:
|
||||
# the client searches for the literal '"access_token" : "'.
|
||||
|
||||
def nucleus_sent_log(addr, size):
|
||||
return "NUCLEUS SENT %s %dB access_token=[REDACTED]" % (addr, size)
|
||||
|
||||
|
||||
def nucleus_handle(raw: socket.socket, addr) -> None:
|
||||
try:
|
||||
raw.settimeout(10)
|
||||
@@ -1649,9 +1702,9 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
|
||||
head, _, rest = req.partition(b"\r\n\r\n")
|
||||
line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else ""
|
||||
log("NUCLEUS REQ %s: %s" % (addr, line0))
|
||||
if head:
|
||||
if head and DUMP_FRAMES:
|
||||
log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace"))
|
||||
if rest:
|
||||
if rest and DUMP_FRAMES:
|
||||
log("NUCLEUS BODY: %r" % rest[:512])
|
||||
|
||||
token = "OPENFUT_" + "".join(
|
||||
@@ -1665,7 +1718,7 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
|
||||
b"Cache-Control: no-store\r\nContent-Length: "
|
||||
+ str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
|
||||
raw.sendall(out)
|
||||
log("NUCLEUS SENT %s %dB access_token=%s" % (addr, len(out), token))
|
||||
log(nucleus_sent_log(addr, len(out)))
|
||||
except Exception as e:
|
||||
log("NUCLEUS ERR %s: %s" % (addr, e))
|
||||
finally:
|
||||
@@ -1717,6 +1770,10 @@ def _selftest() -> None:
|
||||
sess.account_locale = 0x656E5553
|
||||
now = 1469000000
|
||||
|
||||
nucleus_summary = nucleus_sent_log(("127.0.0.1", 1234), 380)
|
||||
assert "[REDACTED]" in nucleus_summary
|
||||
assert "OPENFUT_selftest_secret" not in nucleus_summary
|
||||
|
||||
# ---- 1. preAuth still round-trips (regression guard vs v2)
|
||||
pre = preauth_response_fields()
|
||||
p = _check_roundtrip("PreAuthResponse", pre)
|
||||
@@ -1740,9 +1797,11 @@ def _selftest() -> None:
|
||||
assert items == client_config_for(cfid), cfid
|
||||
print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes"
|
||||
% (cfid, len(items), len(pb)))
|
||||
assert client_config_for("TOTALLY_UNKNOWN") == [], "unknown CFID must be []"
|
||||
shared = sorted(FUT_RS4_CONFIG + FUT_CONTENT_CONFIG + OSDK_POW)
|
||||
assert client_config_for("TOTALLY_UNKNOWN") == shared, \
|
||||
"unknown CFID must carry only the shared merged-store rows"
|
||||
assert len(fetch_config_response_fields("TOTALLY_UNKNOWN")) == 1, \
|
||||
"unknown CFID must still carry a CONF field (empty map, not empty frame)"
|
||||
"unknown CFID must still carry a CONF field"
|
||||
|
||||
# ---- 3. LoginResponse
|
||||
lr = login_response_fields(sess)
|
||||
|
||||
@@ -204,6 +204,7 @@ class Account:
|
||||
def __init__(self, path=None):
|
||||
self.path = path or ACCOUNT_PATH
|
||||
self._loaded = False
|
||||
self._file_signature = None
|
||||
self._stored = {} # what is on disk (tier 2+3 only)
|
||||
for f in _FIELDS:
|
||||
setattr(self, "_" + f, None)
|
||||
@@ -214,7 +215,8 @@ class Account:
|
||||
save the first time. Never raises on a malformed file -- a broken
|
||||
account file must not stop the harness booting."""
|
||||
with _LOCK:
|
||||
if self._loaded and not force:
|
||||
signature = self._signature()
|
||||
if self._loaded and not force and signature == self._file_signature:
|
||||
return self
|
||||
stored = {}
|
||||
if os.path.exists(self.path):
|
||||
@@ -239,8 +241,22 @@ class Account:
|
||||
% (self.path, e))
|
||||
self._stored = stored
|
||||
self._loaded = True
|
||||
self._file_signature = self._signature()
|
||||
return self
|
||||
|
||||
def _signature(self):
|
||||
"""Identity of the active-account file across atomic replacements.
|
||||
|
||||
The launcher can select an account while Blaze/POW are already running
|
||||
in separate processes. inode + mtime + size lets every process notice
|
||||
the replacement on its next property read without restarting Docker.
|
||||
"""
|
||||
try:
|
||||
st = os.stat(self.path)
|
||||
return st.st_dev, st.st_ino, st.st_mtime_ns, st.st_size
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
def _migrate_from_profile(self):
|
||||
"""Lift identity/club out of a pre-existing fifa17_profile.json so an
|
||||
existing club name survives the move to this module. Read-only: the game
|
||||
@@ -266,11 +282,32 @@ class Account:
|
||||
return out
|
||||
|
||||
def _write(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._stored, f, indent=1, sort_keys=True)
|
||||
f.write("\n")
|
||||
os.replace(tmp, self.path)
|
||||
self._file_signature = self._signature()
|
||||
|
||||
def replace(self, values):
|
||||
"""Atomically replace the active identity with validated persisted values."""
|
||||
with _LOCK:
|
||||
clean = {k: v for k, v in values.items() if k in _FIELDS and v is not None}
|
||||
if "persona_id" not in clean or "persona_name" not in clean:
|
||||
raise ValueError("persona_id and persona_name are required")
|
||||
clean["persona_id"] = int(clean["persona_id"])
|
||||
clean["persona_name"] = str(clean["persona_name"]).strip()
|
||||
if clean["persona_id"] <= 0 or not clean["persona_name"]:
|
||||
raise ValueError("persona_id must be positive and persona_name must not be empty")
|
||||
self._stored = clean
|
||||
for field in _FIELDS:
|
||||
setattr(self, "_" + field, None)
|
||||
self._loaded = True
|
||||
self._write()
|
||||
return self
|
||||
|
||||
def save(self):
|
||||
"""Persist tiers 2+3 (only fields that differ from the built-in default,
|
||||
|
||||
@@ -17,7 +17,19 @@ 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"))
|
||||
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 ------------------
|
||||
#
|
||||
@@ -386,18 +398,51 @@ class Store:
|
||||
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.
|
||||
|
||||
|
||||
@@ -574,7 +574,7 @@ def serve(sock, addr):
|
||||
def main():
|
||||
s = socket.socket()
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind(("127.0.0.1", 4216))
|
||||
s.bind((os.environ.get("OPENFUT_BIND", "127.0.0.1"), 4216))
|
||||
s.listen(8)
|
||||
log("v2 listening on 127.0.0.1:4216 (start FIFA 17 now)")
|
||||
log(f"login-state event push: {'ENABLED' if EVENTS_ENABLED else 'DISABLED'}"
|
||||
|
||||
@@ -20,7 +20,7 @@ HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CERT = os.path.join(HERE, "redir_cert.pem")
|
||||
KEY = os.path.join(HERE, "redir_key.pem")
|
||||
LOG = "/tmp/roster_server.log"
|
||||
ADDR = ("127.0.0.1", 8081)
|
||||
ADDR = (os.environ.get("OPENFUT_BIND", "127.0.0.1"), 8081)
|
||||
|
||||
# Minimal "no update available" roster body. Unknown-format -> iterate from the log.
|
||||
ROSTER_XML = b'<?xml version="1.0" encoding="utf-8"?>\n<rosterupdate version="0"/>\n'
|
||||
|
||||
@@ -105,14 +105,18 @@ def test_store_catalog():
|
||||
for p in d.get("purchase", []):
|
||||
check("pack has assetId (real identity)", "assetId" in p, repr(p.get("assetId")))
|
||||
check("pack.packContentInfo is object", is_obj(p.get("packContentInfo")))
|
||||
if not p.get("unopened"):
|
||||
# The inactive zero-item sentinel keeps FIFA's hardcoded `mypacks`
|
||||
# navigation destination resolvable when no owned packs remain. It is
|
||||
# intentionally neither owned nor purchasable and therefore has no
|
||||
# pricing. Validate prices only for active store packs.
|
||||
if p.get("state") == "active" and not p.get("unopened"):
|
||||
check("store pack currencies is array (coin price)",
|
||||
is_arr(p.get("currencies")))
|
||||
check("store pack extPrice is object", is_obj(p.get("extPrice")))
|
||||
ep = p.get("extPrice", {})
|
||||
check("store extPrice.finalPrice is object",
|
||||
is_obj(ep.get("finalPrice")))
|
||||
else:
|
||||
elif p.get("unopened"):
|
||||
check("owned pack omits purchase currencies", "currencies" not in p)
|
||||
check("owned pack omits external purchase price", "extPrice" not in p)
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ Guards the two things that would silently break the loop:
|
||||
* `destroy_match_body()` drifting from FutDestroyMatchServerResponse
|
||||
(deser 0x180121b60): a non-scalar there is the freeze class at 0x1801c7f1a,
|
||||
and a renamed key is silently SKIP'd, i.e. the reward vanishes with no error.
|
||||
* the shared base `/match` path distinguishing CREATEMATCH from PLAYGAME by
|
||||
the body-level matchId that CardsDLL serializes for subsequent operations
|
||||
|
||||
Run: python3 tools/test_match_rewards.py (exit 0 = pass)
|
||||
"""
|
||||
@@ -152,9 +154,37 @@ def test_payout_table():
|
||||
check("draw pays >= loss", U.MATCH_COINS["draw"] >= U.MATCH_COINS["loss"])
|
||||
|
||||
|
||||
def test_match_call_classification():
|
||||
"""CREATEMATCH and PLAYGAME share a path; only the latter has a matchId."""
|
||||
cases = (
|
||||
("/ut/game/fifa17/match", "POST", {}, "create"),
|
||||
("/ut/game/fifa17/match", "POST", {"matchId": 1234}, "play"),
|
||||
("/ut/game/fifa17/match/ready", "POST", {"matchId": 1234}, "ready"),
|
||||
("/ut/game/fifa17/match/end", "POST", {"matchId": 1234}, "end"),
|
||||
("/ut/game/fifa17/match/reset", "PUT", {"matchId": 1234}, "reset"),
|
||||
("/ut/game/fifa17/match/keepalive", "POST", {"matchId": 1234}, "keepalive"),
|
||||
)
|
||||
for path, method, body, want in cases:
|
||||
got = U._match_call(path, method, body)
|
||||
check("%s %s -> %s" % (method, path, want), got == want, "got %s" % got)
|
||||
|
||||
|
||||
def test_match_ready_body():
|
||||
"""FutMatchReadyServerResponse parses these two scalar identifiers."""
|
||||
body = U.match_ready_body(1234, 33068179)
|
||||
check("ready echoes matchId", body.get("matchId") == 1234, repr(body))
|
||||
check("ready has opponentPersonaId", body.get("opponentPersonaId") == 33068179,
|
||||
repr(body))
|
||||
check("ready IDs are scalar ints",
|
||||
all(isinstance(v, int) and not isinstance(v, bool) for v in body.values()),
|
||||
repr(body))
|
||||
check("ready omits unproven nested items", "items" not in body, repr(body))
|
||||
|
||||
|
||||
def main():
|
||||
for t in (test_result_detection, test_reward_body,
|
||||
test_end_reason_is_authoritative, test_payout_table):
|
||||
test_end_reason_is_authoritative, test_payout_table,
|
||||
test_match_call_classification, test_match_ready_body):
|
||||
try:
|
||||
t()
|
||||
except Exception as e:
|
||||
|
||||
@@ -12,6 +12,7 @@ Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
|
||||
* [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET.
|
||||
"""
|
||||
import copy, datetime, json, os, random, re, sys, http.server
|
||||
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from fut_seed import CLUB, SQUAD, USER_LIST, squad_summary # forged starter squad (clean-room)
|
||||
@@ -19,13 +20,14 @@ from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item, player_
|
||||
import fut_cards
|
||||
import fut_staff
|
||||
from fut_account import ACCOUNT, validate_club # identity + club, single source
|
||||
from fut_accounts import activate as activate_account
|
||||
|
||||
# FUT_PORT exists so a second, THROWAWAY instance can be started without touching the
|
||||
# one the live client is talking to. Research agents kept bouncing the live server
|
||||
# because the only way to exercise a route was to restart the only server there was;
|
||||
# with this plus FUT_PROFILE (a copy of the save) and FUT_TEST_BASE, a test run is
|
||||
# fully isolated. The default stays 8099: that is the port the hook redirects to.
|
||||
ADDR = ("127.0.0.1", int(os.environ.get("FUT_PORT", "8099")))
|
||||
ADDR = (os.environ.get("OPENFUT_BIND", "127.0.0.1"), int(os.environ.get("FUT_PORT", "8099")))
|
||||
LOG = os.environ.get("FUT_LOG", "/tmp/utas_server.log")
|
||||
SID = "OPENFUT-SID-0000000000000001"
|
||||
# IDENTITY NOTE: there are no PERSONA_ID / PERSONA_NAME literals in this file any
|
||||
@@ -61,6 +63,96 @@ def log(m):
|
||||
f.write(line + "\n")
|
||||
|
||||
|
||||
def safe_request_path(path):
|
||||
"""Redact legacy phishing answers before ordinary request logging."""
|
||||
parts = urlsplit(path)
|
||||
query = []
|
||||
for key, value in parse_qs(parts.query, keep_blank_values=True).items():
|
||||
query.extend((key, "[REDACTED]" if key.lower() == "answer" else item)
|
||||
for item in value)
|
||||
return urlunsplit((parts.scheme, parts.netloc, parts.path,
|
||||
urlencode(query), parts.fragment))
|
||||
|
||||
|
||||
_SECRET_HEADERS = {
|
||||
"authorization", "cookie", "set-cookie", "x-ut-sid", "x-pow-sid",
|
||||
}
|
||||
|
||||
|
||||
def safe_header_for_log(name, value):
|
||||
"""Return a diagnostic-safe HTTP header value."""
|
||||
if name.lower() in _SECRET_HEADERS:
|
||||
return "[REDACTED]"
|
||||
return value
|
||||
|
||||
|
||||
_PHISHING_HEX32 = re.compile(r"^[0-9a-fA-F]{32}$")
|
||||
|
||||
|
||||
def security_question_route(h):
|
||||
"""Emulate FIFA 17's retired FUT phishing/security-question service.
|
||||
|
||||
Clean-room CardsDLL evidence:
|
||||
GET /question?deviceId=%s parses question/attempts/recoverAttempts.
|
||||
POST /validate?deviceId=%s&answer=%s parses no response fields.
|
||||
/trusteddevice parses changed/exists/locked/trusted booleans.
|
||||
|
||||
The answer is an opaque client-transformed 32-hex value. Successful legacy
|
||||
set/validate calls have empty response contracts, so OpenFUT acknowledges a
|
||||
well-formed value without retaining or comparing it. Account selection has
|
||||
already initialized the server-owned verified compatibility state.
|
||||
"""
|
||||
if h.headers.get("X-UT-SID") != SID:
|
||||
log("[FUT] security-question request has no matching OpenFUT session")
|
||||
return 400, {"reason": "invalid_session"}
|
||||
|
||||
parts = urlsplit(h.path)
|
||||
action = parts.path.rstrip("/").rsplit("/", 1)[-1]
|
||||
params = parse_qs(parts.query, keep_blank_values=True)
|
||||
device_id = params.get("deviceId", [""])[0]
|
||||
if not _PHISHING_HEX32.fullmatch(device_id):
|
||||
log("[FUT] malformed security-question device identifier")
|
||||
return 400, {"reason": "malformed_request"}
|
||||
|
||||
state = STORE.ensure_security_question()
|
||||
log("[FUT] security-question %s request" % action)
|
||||
log("[FUT] profile security state: %s"
|
||||
% ("initialized" if state.get("verified") else "not initialized"))
|
||||
|
||||
if action == "trusteddevice":
|
||||
if h.command != "GET":
|
||||
return 405, {"reason": "method_not_allowed"}
|
||||
log("[FUT] returning verified trusted-device response")
|
||||
return 200, {
|
||||
"changed": False,
|
||||
"exists": True,
|
||||
"locked": False,
|
||||
"trusted": True,
|
||||
}
|
||||
|
||||
if action == "question" and h.command == "GET":
|
||||
return 200, {"question": 0, "attempts": 5, "recoverAttempts": 0}
|
||||
|
||||
if action == "question" and h.command in ("POST", "PUT"):
|
||||
answer = params.get("answer", [""])[0]
|
||||
question = params.get("question", [""])[0]
|
||||
if not question.isdigit() or not _PHISHING_HEX32.fullmatch(answer):
|
||||
log("[FUT] malformed security-question setup request")
|
||||
return 400, {"reason": "malformed_request"}
|
||||
log("[FUT] security-question compatibility setup completed")
|
||||
return 200, {}
|
||||
|
||||
if action == "validate" and h.command == "POST":
|
||||
answer = params.get("answer", [""])[0]
|
||||
if not _PHISHING_HEX32.fullmatch(answer):
|
||||
log("[FUT] malformed security-question validation request")
|
||||
return 400, {"reason": "malformed_request"}
|
||||
log("[FUT] security-question accepted")
|
||||
return 200, {}
|
||||
|
||||
return 405, {"reason": "method_not_allowed"}
|
||||
|
||||
|
||||
# ---- payloads -------------------------------------------------------------
|
||||
def auth_body(h=None):
|
||||
"""POST ut/auth.
|
||||
@@ -104,6 +196,19 @@ def auth_body(h=None):
|
||||
return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()}
|
||||
|
||||
|
||||
def account_sync_route(h):
|
||||
"""Launcher-only active-profile selection, before LSX/Blaze login starts."""
|
||||
try:
|
||||
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
|
||||
account = activate_account(body)
|
||||
except (ValueError, TypeError) as error:
|
||||
return 400, {"error": str(error)}
|
||||
log(" ACCOUNT: selected %s/%r profile=%s coins=%s unopened=%s"
|
||||
% (account["personaId"], account["personaName"], account["profilePath"],
|
||||
account["coins"], account["unopenedPacks"]))
|
||||
return 200, {"account": account, "status": "OK"}
|
||||
|
||||
|
||||
def current_squad():
|
||||
"""The squad the client should see: the persisted one (item refs re-embedded
|
||||
from the club) or the seed ladder squad on first run.
|
||||
@@ -1095,6 +1200,9 @@ def item_route(h):
|
||||
|
||||
G = r"/ut/game/[^/]+"
|
||||
ROUTES = [
|
||||
# Launcher control-plane endpoint. It is intentionally outside /ut so FIFA
|
||||
# never calls it; launch is blocked unless this succeeds first.
|
||||
(re.compile(r"^/openfut/account/sync$"), lambda m, h: account_sync_route(h)),
|
||||
# ---- FUT item-definition endpoints (must precede generic /item, /user) ----
|
||||
(re.compile(G + r"/item/resource"), lambda m, h: defs_route(h)),
|
||||
(re.compile(G + r"/defid"), lambda m, h: defs_route(h)),
|
||||
@@ -1117,12 +1225,10 @@ ROUTES = [
|
||||
(re.compile(r"^/ut/auth"), lambda m, h: (200, auth_body(h))),
|
||||
(re.compile(r"^/ut/delete/auth"), lambda m, h: (200, {})),
|
||||
(re.compile(G + r"/settings"), lambda m, h: (200, SETTINGS)),
|
||||
# Device-trust ("phishing") flow. trusteddevice parser 0x18012a170 reads 4
|
||||
# booleans by key-id 0x7e/0x117/0x19e/0x351; 0x351 == JSON key "trusted".
|
||||
# Returning trusted=true makes FUT SKIP the security question.
|
||||
(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/question"), lambda m, h: (200, {"question": 0, "answer": "", "attempts": 5})),
|
||||
# Device-trust ("phishing") flow. One handler owns its exact state machine,
|
||||
# validation, persistence and redacted diagnostics; keep these above /user.
|
||||
(re.compile(G + r"/phishing/(trusteddevice|validate|question)"),
|
||||
lambda m, h: security_question_route(h)),
|
||||
(re.compile(G + r"/user/credits"), lambda m, h: credits_route(h)),
|
||||
# ---- club/squad routes (2026-08-03: squad schema 0x18013d1f0 now reversed) ----
|
||||
# /user, /squad and /userMassInfo serve real data again -- the squad object
|
||||
@@ -1343,8 +1449,23 @@ def hub_data():
|
||||
players = len([i for i in STORE.items() if _is_player(i)])
|
||||
auctions = len(STORE.listings())
|
||||
log(" HUB: clubPlayers=%d auctionCount=%d selling=%d" % (players, auctions, auctions))
|
||||
return {"clubPlayers": players, "auctionCount": auctions,
|
||||
body = {"clubPlayers": players, "auctionCount": auctions,
|
||||
"tradePile": {"count": auctions, "selling": auctions, "sold": 0}}
|
||||
if _MODES:
|
||||
# GetHubData's parser 0x180139610 recognises offlineSeason (atom 0x1ec)
|
||||
# and passes it to 0x18013c3a0. The nested scalar fields are STRING
|
||||
# getters, despite representing numbers. Omitting the object leaves the
|
||||
# offline-season summary invalid and the UI aborts before requesting
|
||||
# /season. The initial division matches season_list()/season_user(); the
|
||||
# ten-game length is a live-test hypothesis, isolated behind FUT_MODES.
|
||||
body["offlineSeason"] = {
|
||||
"divisionId": "10",
|
||||
"gamesPlayed": "0",
|
||||
"points": "0",
|
||||
"totalGames": "10",
|
||||
"progressDataVersion": "0",
|
||||
}
|
||||
return body
|
||||
|
||||
|
||||
# ---- club stats: the CLUB STATS panel, and probably the MY CLUB tile too ------
|
||||
@@ -2206,12 +2327,11 @@ def clientdata_route(h):
|
||||
"""ut/%s/clientdata/<key> -- opaque client blob storage.
|
||||
|
||||
LIVE-OBSERVED: `PUT ut/game/fifa17/clientdata/userHubData` fires from the FUT
|
||||
hub (20:40 session). The client is storing its own hub state -- so the correct
|
||||
server behaviour is to keep the blob and hand back exactly what was given, which
|
||||
is zero-risk by construction: we never synthesise a shape, we echo the client's
|
||||
own bytes. Persisting it is also the most plausible route to the hub's
|
||||
"MANAGER TASKS 0/0" tile surviving a relaunch, since no FutGetObjectives class
|
||||
exists in the binary at all (§9) -- the tile state may simply live in this blob.
|
||||
hub. Persist the client-owned blob so its matching GET can restore it. The PUT
|
||||
acknowledgement remains the historical empty object: echoing the body was
|
||||
exercised live with both observed values ([3,0] and [3,1]) and did not unlock
|
||||
offline Seasons or produce a subsequent /season request. No response schema has
|
||||
been recovered for SetTutData, so do not infer one from the request shape.
|
||||
"""
|
||||
key = h.path.split("/clientdata/", 1)[-1].split("?")[0] or "default"
|
||||
if h.command in ("PUT", "POST"):
|
||||
@@ -2283,10 +2403,17 @@ def season_user():
|
||||
|
||||
|
||||
def tournament_list():
|
||||
"""GET ut/%s/tournament -- FutTournamentList, deser 0x180169ef0 (MEDIUM).
|
||||
ARRAY root; rounds/prizeSet/staff/kit atoms are nested FREEZE-RISK -> omitted."""
|
||||
return [{"id": 1, "difficulty": 1, "coins": 500, "rewardMultiplier": 1,
|
||||
"assetName": "", "eligibilityOperation": ""}]
|
||||
"""GET ut/%s/tournament -- object wrapper parsed at 0x18016b220 (HIGH).
|
||||
|
||||
The response parser recognizes only tournament(0x328), opens its ARRAY, then
|
||||
invokes the element parser at 0x180169ef0. A bare array populates nothing.
|
||||
rounds(0x292) and elgReq(0xf7) are nested ARRAY loops and remain omitted.
|
||||
The wrapper/root shape is recovered; element semantics remain live-unverified.
|
||||
"""
|
||||
return {"tournament": [
|
||||
{"id": 1, "difficulty": 1, "coins": 500, "rewardMultiplier": 1,
|
||||
"assetName": "", "eligibilityOperation": ""},
|
||||
]}
|
||||
|
||||
|
||||
def tournament_user():
|
||||
@@ -2989,14 +3116,46 @@ def destroy_match_body(result, coins, total):
|
||||
return body
|
||||
|
||||
|
||||
def _match_call(path, method, body):
|
||||
"""Classify one of CardsDLL's six match calls.
|
||||
|
||||
The RPC descriptor block gives READY/END/RESET/KEEPALIVE explicit suffixes.
|
||||
CREATEMATCH and PLAYGAME both use the bare ``ut/%s/match`` path; CardsDLL
|
||||
serializes atom ``matchId`` as an integer for operations on an existing
|
||||
match, which is the discriminator for PLAYGAME. HTTP verbs are intentionally
|
||||
not used for that pair because method selection lives outside CardsDLL.
|
||||
"""
|
||||
clean = path.split("?", 1)[0].rstrip("/")
|
||||
for suffix, call in (("/ready", "ready"), ("/end", "end"),
|
||||
("/reset", "reset"), ("/keepalive", "keepalive")):
|
||||
if clean.endswith(suffix):
|
||||
return call
|
||||
if method == "DELETE" or "/ut/delete/" in clean:
|
||||
return "end"
|
||||
if isinstance(body, dict) and isinstance(body.get("matchId"), int):
|
||||
return "play"
|
||||
return "create"
|
||||
|
||||
|
||||
def match_ready_body(match_id, opponent_persona_id):
|
||||
"""Minimal FutMatchReadyServerResponse (CardsDLL parser 0x1801205d0).
|
||||
|
||||
The parser has scalar ``matchId`` and ``opponentPersonaId`` members plus a
|
||||
nested ``items`` member. The latter remains omitted until its opponent-squad
|
||||
item contract is recovered; unrecognized/absent members are skip-safe.
|
||||
"""
|
||||
return {"matchId": int(match_id),
|
||||
"opponentPersonaId": int(opponent_persona_id)}
|
||||
|
||||
|
||||
def match_route(h):
|
||||
"""POST create / PUT ready / POST play / DELETE destroy(+rewards)."""
|
||||
"""Create / ready / play / destroy(+rewards) on CardsDLL's match paths."""
|
||||
try:
|
||||
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
|
||||
except Exception:
|
||||
body = {}
|
||||
m = re.search(r"/match/(\d+)", h.path)
|
||||
match_id = int(m.group(1)) if m else None
|
||||
url_match_id = int(m.group(1)) if m else None
|
||||
# THE REAL URLS, from the RPC descriptor block (rows 49-54, all using template
|
||||
# index 16 = `ut/%s/match`, each appending a fixed suffix via the params object
|
||||
# at slot +0x08): CREATEMATCH and PLAYGAME append nothing, MATCHREADY `/ready`,
|
||||
@@ -3013,10 +3172,11 @@ def match_route(h):
|
||||
# and accept any verb. A reviewer specifically flagged the claim "the reward path
|
||||
# can never fire" as overreach on exactly this point, since the verb is unknown
|
||||
# rather than known-wrong, so this widens the gate instead of replacing it.
|
||||
is_delete = (h.command == "DELETE" or "/ut/delete/" in h.path
|
||||
or (MATCH_END and h.path.split("?")[0].endswith("/match/end")))
|
||||
call = _match_call(h.path, h.command, body)
|
||||
body_match_id = body.get("matchId") if isinstance(body, dict) else None
|
||||
match_id = body_match_id if isinstance(body_match_id, int) else url_match_id
|
||||
|
||||
if is_delete:
|
||||
if call == "end":
|
||||
# FutDestroyMatch -- the ONLY place a match awards anything.
|
||||
result, score = _match_result(body)
|
||||
coins = MATCH_COINS.get(result, 0) + MATCH_PARTICIPATION
|
||||
@@ -3026,15 +3186,24 @@ def match_route(h):
|
||||
rec["won"], rec["draw"], rec["loss"]))
|
||||
return 200, destroy_match_body(result, coins, total)
|
||||
|
||||
if h.command == "POST" and match_id is None:
|
||||
if call == "create":
|
||||
# FutCreateMatch. `squad` is nested + freeze-risky -> omitted (SKIP-safe).
|
||||
mid = STORE.new_item_id()
|
||||
log(" MATCH: created id=%d" % mid)
|
||||
return 200, {"startDateTime": int(datetime.datetime.now().timestamp()),
|
||||
"reportIdEnabled": False, "id": mid}
|
||||
|
||||
# PUT {id} = MatchReady, POST {id} = PlayGame. Both have NO deserializer at
|
||||
# all, so {} is a complete response; the result is claimed on destroy.
|
||||
if call == "ready":
|
||||
# FutMatchReadyServerResponse has two scalar IDs and an optional nested
|
||||
# item list. Preserve an explicit opponent supplied by the request. For
|
||||
# offline AI the value is not yet live-confirmed; zero is deliberately a
|
||||
# TODO/CONFIRM neutral placeholder, never the selected user's persona.
|
||||
opponent_id = body.get("opponentPersonaId", 0) if isinstance(body, dict) else 0
|
||||
if not isinstance(opponent_id, int):
|
||||
opponent_id = 0
|
||||
return 200, match_ready_body(match_id or 0, opponent_id)
|
||||
|
||||
# FutPlayGameServerResponse has no parsed fields. The result is claimed on end.
|
||||
if body:
|
||||
log(" MATCH: %s %s body=%s" % (h.command, h.path, json.dumps(body)[:400]))
|
||||
return 200, {}
|
||||
@@ -3328,13 +3497,18 @@ def purchased_items(h):
|
||||
if pack.get("ownedOnly") and not STORE.consume_unopened_pack(pid):
|
||||
log(" STORE: rejected unopened pack %s; no owned instance" % pid)
|
||||
return 200, {"itemData": STORE.last_pack()}
|
||||
if pack.get("ownedOnly"):
|
||||
_OPENED_PACK_GRACE.append(pid)
|
||||
items = STORE.open_pack(pack["price"], pack["count"], pack["gold"],
|
||||
pack.get("tiers"), pack.get("specialChance", 0.0),
|
||||
pack.get("playersOnly", False))
|
||||
if items is None:
|
||||
return 461, {"reason": "insufficient_coins", "credits": STORE.coins()}
|
||||
# FIFA always returns to its hard-coded `mypacks` group after the reveal,
|
||||
# including for an ordinary coin-purchased pack. Keep one owned-shaped
|
||||
# catalogue copy alive until the next hub request; otherwise that group
|
||||
# contains only the inactive sentinel and FIFA shows "The pack you've
|
||||
# selected is currently not available" after a successful opening.
|
||||
if pid not in _OPENED_PACK_GRACE:
|
||||
_OPENED_PACK_GRACE.append(pid)
|
||||
log(" STORE: POST /purchased opened pack %s -> %d items, coins=%d"
|
||||
% (pack["name"], len(items), STORE.coins()))
|
||||
if PACK_AUTOCLUB:
|
||||
@@ -3555,9 +3729,9 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
n = int(self.headers.get("Content-Length", 0) or 0)
|
||||
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, safe_request_path(self.path)))
|
||||
for k, v in self.headers.items():
|
||||
log(" %s: %s" % (k, v))
|
||||
log(" %s: %s" % (k, safe_header_for_log(k, v)))
|
||||
if body:
|
||||
log(" body: %s" % body[:65536].decode("utf-8", "replace"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user