Files
OpenFUT/fifa17-recon/tools/test_match_rewards.py
T
funman300 24bbc32da5 fifa17-recon: fix the match tail -- real URLs, endReason, and coins in the right place
The whole match family is one RPC descriptor block (rows 49-54, every row using URL
template index 16 = `ut/%s/match`) with a fixed suffix appended per call:

  CREATEMATCH  ut/game/fifa17/match          PLAYGAME    ut/game/fifa17/match
  MATCHREADY   ut/game/fifa17/match/ready    DESTROYMATCH ut/game/fifa17/match/end
  RESETMATCH   ut/game/fifa17/match/reset    KEEPALIVE   ut/game/fifa17/match/keepalive

THERE IS NO /match/{id} URL. The id travels in the body. Our reward path was gated on
`h.command == "DELETE" or "/ut/delete/" in h.path` and extracted the id with
re.search(r"/match/(\d+)"), so it was waiting for a request the client does not make.
The gate is now widened to include a /match/end path with ANY verb, because the verb
genuinely cannot be determined statically: the strings "PUT" and "DELETE" do not exist
anywhere in cardsdll.dll (0 hits each), so verb selection happens outside this DLL. A
reviewer flagged "the reward path can never fire" as overreach on exactly that point;
widening rather than replacing the gate is the response.

THE RESULT SIGNAL IS `endReason` (atom 260), a STRING enum with nine values: WIN DRAW
LOSS DNF QUIT NO_CONTEST DNF_WIN DNF_DRAW DNF_LOSS. Not a score comparison. The score
lives in `myMatchStats.goals` / `opponentMatchStats.goals`, two literal-keyed objects
of 15 int fields each, and the client OMITS both when endReason is DNF or QUIT, so
nothing may require them. _match_result() now reads endReason first and keeps the old
spelling probe only as a fallback, because request-side static findings are a floor:
PUT /item's swap/tradeId appeared in no static listing either.

THREE CORRECTIONS TO THE RESPONSE, all of which were shipping wrong:

1. `coins` (atom 149) is NOT a top-level key. It is read only inside `gameModeAward`.
   The one field most obviously named "the reward" was being silently skipped.
2. `qualifiedChampionEventId` (0x269) has a SIDE EFFECT: its branch calls through a
   manager vtable after storing. Sending a habitual zero poked champion-event
   machinery for no benefit. Removed.
3. `bidTokens` (atom 89) inside gameModeAward is MATCHED and then handled by nothing,
   so its value token is left unconsumed. That is the precondition for the desync
   spin. A freeze trap dressed as an ordinary field; now guarded by a unit check.

test_match_rewards.py rewrote its expectations. The old version asserted a top-level
`coins` and passed happily while the server shipped a body whose reward field the
client never read. A test that encodes the wrong schema converts a bug into a
guarantee. New test_end_reason_is_authoritative covers all nine enum values, the
stats-less DNF case, and that endReason beats a contradictory score probe.

DEFAULT ON (FUT_MATCH_END=0 reverts), a reasoned exception to the flag convention:
nothing here is live-proven because no match has ever been played, and the old
behaviour is not a working screen but a path that provably could not fire.

61 unit checks (58 with the flag off), 392 contract checks green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 13:47:05 -07:00

170 lines
7.9 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.
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 main():
for t in (test_result_detection, test_reward_body,
test_end_reason_is_authoritative, test_payout_table):
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())