83539e33ec
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).
200 lines
9.4 KiB
Python
200 lines
9.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Unit tests for the FUT match core loop — PURE, no server, no state, no profile.
|
|
|
|
Why separate from test_fut_contract.py: that suite is read-only by design (it hits
|
|
a live server and must never mutate the save), but the match loop credits coins and
|
|
bumps the W/D/L record. So the two pure pieces — result detection and the reward
|
|
body — are tested here instead of making the HTTP suite stateful.
|
|
|
|
Guards the two things that would silently break the loop:
|
|
* `_match_result()` mis-reading a scoreline (wrong result -> wrong payout)
|
|
* `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)
|
|
"""
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
os.environ.setdefault("FUT_PROFILE", "/tmp/openfut_unittest_profile.json")
|
|
|
|
import utas_server as U # noqa: E402
|
|
|
|
_fail = []
|
|
_pass = 0
|
|
|
|
|
|
def check(name, cond, detail=""):
|
|
global _pass
|
|
if cond:
|
|
_pass += 1
|
|
else:
|
|
_fail.append("%s: %s" % (name, detail))
|
|
|
|
|
|
# ---- _match_result: scoreline -> outcome ------------------------------------
|
|
def test_result_detection():
|
|
cases = [
|
|
({"goals": 3, "opponentGoals": 1}, "won"),
|
|
({"goals": 0, "opponentGoals": 2}, "loss"),
|
|
({"goals": 1, "opponentGoals": 1}, "draw"),
|
|
({"score": 2, "opponentScore": 0}, "won"),
|
|
({"homeGoals": 0, "awayGoals": 4}, "loss"),
|
|
({"match": {"goals": 5, "opponentGoals": 0}}, "won"), # nested
|
|
({"stats": {"score": 0, "opponentScore": 3}}, "loss"), # nested
|
|
({"result": "WIN"}, "won"),
|
|
({"outcome": "defeat"}, "loss"),
|
|
({"result": "tie"}, "draw"),
|
|
({}, "draw"), # unknown -> neutral fallback
|
|
(None, "draw"), # malformed body -> neutral fallback
|
|
({"goals": "2", "opponentGoals": 1}, "draw"), # non-int -> no guess
|
|
]
|
|
for body, expect in cases:
|
|
got, _ = U._match_result(body)
|
|
check("result %r -> %s" % (body, expect), got == expect, "got %s" % got)
|
|
|
|
# a 0-0 draw must not be mistaken for "no data"
|
|
r, s = U._match_result({"goals": 0, "opponentGoals": 0})
|
|
check("0-0 is a draw with a score", r == "draw" and s == (0, 0), "%s %s" % (r, s))
|
|
|
|
|
|
# ---- destroy_match_body: the reward record ----------------------------------
|
|
# These expectations were REWRITTEN on 2026-08-04 after reversing deser 0x180121b60
|
|
# properly. The previous version asserted a top-level `coins` and a
|
|
# `qualifiedChampionEventId`, and it passed happily while the server shipped a body
|
|
# whose reward field the client never read. A test that encodes the wrong schema is
|
|
# worse than no test: it converts a bug into a guarantee.
|
|
REQUIRED_INT = ("allCoins", "matchCoins", "seasonCoins", "tournamentCoins",
|
|
"boostConis", "participationAward")
|
|
|
|
|
|
def test_reward_body():
|
|
b = U.destroy_match_body("won", 400, 13000)
|
|
for k in REQUIRED_INT:
|
|
check("reward.%s present" % k, k in b)
|
|
check("reward.%s is int (scalar, not nested)" % k,
|
|
isinstance(b.get(k), int) and not isinstance(b.get(k), bool), repr(b.get(k)))
|
|
check("reward.teamOfTournamentWinner is bool",
|
|
isinstance(b.get("teamOfTournamentWinner"), bool), repr(b.get("teamOfTournamentWinner")))
|
|
check("allCoins is the NEW balance", b["allCoins"] == 13000, repr(b["allCoins"]))
|
|
# EA's typo is load-bearing: the atom is 96 == "boostConis", not "boostCoins".
|
|
check("key is EA's misspelled boostConis", "boostConis" in b and "boostCoins" not in b,
|
|
repr(sorted(b)))
|
|
# userData and matchCoinMultipliers stay OUT (SKIP-safe; userData is freeze-risk)
|
|
for k in ("userData", "matchCoinMultipliers"):
|
|
check("reward omits nested %s" % k, k not in b)
|
|
|
|
if U.MATCH_END:
|
|
# `coins` (atom 149) is read ONLY inside gameModeAward. A top-level one is
|
|
# silently skipped, which is what the server used to send.
|
|
check("coins is NOT top level", "coins" not in b, repr(sorted(b)))
|
|
gma = b.get("gameModeAward")
|
|
check("gameModeAward is an object", isinstance(gma, dict), repr(gma))
|
|
if isinstance(gma, dict):
|
|
check("gameModeAward.coins echoes the credited amount",
|
|
gma.get("coins") == 400, repr(gma.get("coins")))
|
|
# atom 89 is MATCHED inside gameModeAward and then handled by nothing:
|
|
# not read, not skip-routed. Its value token is left unconsumed, which is
|
|
# the precondition for the desync spin. This check is the guard rail.
|
|
check("gameModeAward NEVER contains bidTokens (freeze trap)",
|
|
"bidTokens" not in gma, repr(sorted(gma)))
|
|
for k, v in gma.items():
|
|
check("gameModeAward.%s is scalar" % k,
|
|
isinstance(v, (int, bool, str)), repr(v))
|
|
# side-effecting atom 0x269: its branch calls through a manager vtable
|
|
check("qualifiedChampionEventId omitted (it has a side effect)",
|
|
"qualifiedChampionEventId" not in b, repr(sorted(b)))
|
|
else:
|
|
check("legacy body keeps top-level coins", b.get("coins") == 400, repr(b.get("coins")))
|
|
|
|
# nothing non-scalar may sneak in at the top level except gameModeAward
|
|
for k, v in b.items():
|
|
if k == "gameModeAward":
|
|
continue
|
|
check("reward.%s is scalar" % k, isinstance(v, (int, bool, str)), repr(v))
|
|
|
|
|
|
def test_end_reason_is_authoritative():
|
|
"""endReason, not a score comparison, is how the client reports the outcome.
|
|
|
|
Reversed from the DestroyMatch serializer: atom 260, a string enum with nine
|
|
values. Both stats objects are OMITTED by the client when endReason is DNF or
|
|
QUIT, so a result parser must not require them.
|
|
"""
|
|
for reason, want in (("WIN", "won"), ("DNF_WIN", "won"),
|
|
("DRAW", "draw"), ("DNF_DRAW", "draw"), ("NO_CONTEST", "draw"),
|
|
("LOSS", "loss"), ("DNF_LOSS", "loss"),
|
|
("DNF", "loss"), ("QUIT", "loss")):
|
|
r, _ = U._match_result({"endReason": reason})
|
|
check("endReason %s -> %s" % (reason, want), r == want, r)
|
|
# score comes from goals in the two stats objects, first field of each
|
|
r, s = U._match_result({"endReason": "WIN",
|
|
"myMatchStats": {"goals": 3},
|
|
"opponentMatchStats": {"goals": 1}})
|
|
check("goals are read from myMatchStats/opponentMatchStats", s == (3, 1), repr(s))
|
|
# a DNF with no stats objects must still resolve, not crash or fall back blindly
|
|
r, s = U._match_result({"endReason": "DNF"})
|
|
check("DNF with no stats resolves to loss with no score", r == "loss" and s is None,
|
|
"%s %s" % (r, s))
|
|
# endReason must WIN over a contradictory score probe
|
|
r, _ = U._match_result({"endReason": "LOSS", "goals": 5, "opponentGoals": 0})
|
|
check("endReason beats the legacy score probe", r == "loss", r)
|
|
|
|
|
|
def test_payout_table():
|
|
for res in ("won", "draw", "loss"):
|
|
b = U.destroy_match_body(res, U.MATCH_COINS[res], 0)
|
|
check("matchCoins matches the %s payout" % res,
|
|
b["matchCoins"] == U.MATCH_COINS[res], repr(b["matchCoins"]))
|
|
check("win pays >= draw", U.MATCH_COINS["won"] >= U.MATCH_COINS["draw"])
|
|
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_match_call_classification, test_match_ready_body):
|
|
try:
|
|
t()
|
|
except Exception as e:
|
|
_fail.append("%s raised %s: %s" % (t.__name__, type(e).__name__, e))
|
|
print("\n%d checks passed, %d failed" % (_pass, len(_fail)))
|
|
for f in _fail:
|
|
print(" FAIL:", f)
|
|
return 0 if not _fail else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|