feat(host): migrate non-economy UTAS routes to Rust + launcher redesign

Host/adapter (deployed to prod-host):
- POST /ut/auth (+/ut/delete/auth): Rust mints sid, opens Rust session, adopts
  persona from body; POST /openfut/account/sync full Rust envelope.
- GET /userMassInfo: full Rust (was proxy+overlay), shared build_user_mass_info.
- GET/PUT /clientdata/<key>: new clientdata_store.rs (JSON-persisted).
- GET /club/stats/{country,league,team}: context-aware club_stats_body
  (nation/league/team buckets).
- GET /store,/match/keepalive,/captcha,/tfa,/livemessage,/activeMessage: StaticAck.
- GET /watchList, /squad/0, /user: Rust handlers.
- host_test.rs updated for the new routing.

Launcher: bump gitlink to c277213 (shareholder-grade redesign + live account panel).

Docs: PRODUCTION_AUTHORITY_MATRIX, PYTHON_RETIREMENT_PLAN, MATCH_LIFECYCLE, and
route-shapes-2026-08-17 reference fixtures for the still-Python tail.
This commit is contained in:
funman300
2026-08-17 16:04:20 +00:00
parent 42fd3c7e90
commit e06fd57211
25 changed files with 2061 additions and 147 deletions
+208
View File
@@ -0,0 +1,208 @@
# FIFA 17 FUT Match Lifecycle
Design + contract reference for the **FUT match loop** as OpenFUT implements it on
the backend/responder side. Consolidates knowledge previously scattered across
`docs/PROJECT_STATE.md`, `fifa17-recon/tools/utas_server.py` (`match_route`),
`openfut-utas-host` (Rust economy END leg), `fifa17-recon/tools/test_match_lifecycle.py`,
and the vault (`Protocol Findings.md`, `Project State.md`, `Known Issues.md`).
Evidence labels: **OBSERVED** (live), **PROVEN** (test/static-analysis),
**HYPOTHESIS** (reasoned, not yet live-confirmed).
> ## Status caveat (read first)
> **No football match has ever started or completed in FIFA against this stack.**
> The lifecycle below is validated by CardsDLL static analysis (RPC descriptor
> blocks) + isolated persistence replay (`test_match_lifecycle.py`), **not** in-game
> acceptance. The reward amounts are FUT-plausible env-tunable defaults, **not**
> reversed values. Two blockers keep `FUT_MODES` **off by default** (see
> [Open questions](#open-questions--blockers)).
## Scope
This documents the **UTAS responder handshake + economy reward** for a match — the
HTTP calls CardsDLL makes around a match and the state they mutate. It does **not**
cover the actual football simulation (Blaze game-server side, "past the FUT hub"),
which remains unverified.
## The loop: a four-call state machine
CardsDLL issues six match RPCs; four form the playable loop. All share the base
path `ut/<sku>/match`; the operation is discriminated by **suffix + body**, not by
HTTP verb (verb selection lives outside CardsDLL, so the classifier is verb-agnostic).
```mermaid
stateDiagram-v2
[*] --> Created: POST /match (no matchId)
Created --> Ready: POST|PUT /match/ready {matchId}
Ready --> Playing: POST /match {matchId} (bare path + int matchId)
Playing --> Ended: POST|PUT|DELETE /match/end {matchId, endReason, ...}
Ended --> [*]: rewards credited, W/D/L + matchesPlayed persisted
```
### Call classifier (`_match_call`, utas_server.py:3325)
The discriminator (**PROVEN** from CardsDLL RPC descriptors):
1. path ends `/match/end`**END** (routed as `FutDestroyMatch` regardless of verb).
2. body has integer `matchId` on the **bare** `/match` path → **PLAY** (`FutPlayGame`).
CREATE and PLAY share `ut/<sku>/match`; the presence of an int `matchId` is the
only discriminator — this is why a bare `/match` carrying `matchId` must NOT
allocate a new match.
3. path ends `/match/ready`**READY** (`FutMatchReady`).
4. otherwise → **CREATE** (`FutCreateMatch`).
## Per-call contracts
### CREATE — `POST /ut/<sku>/match` (empty/no `matchId`)
CardsDLL: `FutCreateMatch` @ `0x180120380`; deserializes `startDateTime`(740,int),
`reportIdEnabled`(641,bool). `squad`(717,nested) is a **FREEZE-RISK** and is omitted
(SKIP-safe).
Response (`match_route`, utas_server.py:3399):
```json
{"startDateTime": <unix_ts:int>, "reportIdEnabled": false, "id": <matchId:int>}
```
Effect: allocates a match id; **advances `nextItemId` by 1** (PROVEN,
`test_match_lifecycle.py:51`).
### READY — `POST|PUT /ut/<sku>/match/ready` (`{matchId}`)
CardsDLL: `FutMatchReady` — no deserializer at all on the request; the server
response parser has two scalar members + one nested member.
Response (`match_ready_body`, utas_server.py:3353):
```json
{"matchId": <int>, "opponentPersonaId": <int, default 0>}
```
- `opponentPersonaId` defaults to `0` — a neutral placeholder, **never** the
logged-in user's persona.
- The parser also has a nested `items` member (the opponent squad). It is **omitted
deliberately** until the opponent-squad item contract is recovered from a live
capture; unrecognized/absent members are skip-safe. **This omission is one of the
two blockers.**
### PLAY — `POST /ut/<sku>/match` (bare path, `{matchId:int}`)
CardsDLL: `FutPlayGame` — no request deserializer.
Response: `{}` (empty). Effect: **none** — must NOT allocate a match or advance
`nextItemId` (PROVEN, `test_match_lifecycle.py:62-63`). This is purely a client
keepalive/transition ack.
### END — `POST|PUT|DELETE /ut/<sku>/match/end` (`{matchId, endReason, myMatchStats, opponentMatchStats}`)
CardsDLL: `FutDestroyMatch` @ `0x180121b60`**the rewards call**. Routed as
DestroyMatch regardless of verb (`FUT_MATCH_END`, default ON).
> **Wire path (Rust vs Python).** The Rust-owned reward is classified by `classify_economy`
> on **`POST /ut/delete/game/<sku>/match`** — EA/CardsDLL tunnels DELETE-semantics ops through the
> `/ut/delete/game/` prefix (`FutDestroyMatch` = `DELETE ut/%s/match/{id}`). Python's `match_route`
> additionally accepts `/ut/game/<sku>/match/end`. Which exact form the retail client emits is
> unverified (no match ever played); the Rust economy owns the `/ut/delete/game` form, and a
> `/ut/game/.../match/end` would fall to Python. Both credit the same reward shape.
Request fields that matter:
- `endReason` (atom 260) — **STRING enum, the AUTHORITATIVE result signal**. A score
comparison is NOT how the client reports the outcome. Nine values, mapped to a
win/draw/loss bucket:
| endReason | bucket |
|---|---|
| `WIN`, `DNF_WIN` | won |
| `DRAW`, `DNF_DRAW`, `NO_CONTEST` | draw |
| `LOSS`, `DNF_LOSS`, `DNF`, `QUIT` | loss |
| (missing/unknown) | draw (neutral fallback — credits without inventing a win) |
- `myMatchStats` / `opponentMatchStats` — literal-keyed objects, 15 int fields each,
first is `goals`. **Omitted by the client when `endReason` is `DNF`/`QUIT`**, so
nothing may require them. Used only as a fallback outcome probe if `endReason` is
absent.
Response (`destroy_match_body` / Rust `build_match_reward_body`) — every field a
**top-level scalar** (zero freeze risk) except the deliberately nested reward:
```json
{
"allCoins": <post-credit balance:int>,
"matchCoins": <per-result coins:int>,
"seasonCoins": 0,
"tournamentCoins": 0,
"boostConis": 0, // EA's typo — exact key required
"participationAward": <int>,
"teamOfTournamentWinner": false,
"gameModeAward": { "coins": <total award:int> }
}
```
> **Critical correction (2026-08-04):** the reward `coins` (atom 149) is read by the
> deserializer **only inside `gameModeAward`**, never as a top-level key. An earlier
> top-level `"coins"` was silently skipped and never reached the client — the one
> field most obviously named "the reward" was the one going nowhere.
Effect (persisted to the active FUT save): credit coins; increment the matching
`record.{won,draw,loss}`; increment `matchesPlayed` (PROVEN,
`test_match_lifecycle.py:74-81`).
## Reward policy
Env-tunable defaults (FUT-plausible, **not reversed**`economy_policy.rs:20-36`,
`utas_server.py:3201-3206`):
| Result | Match coins | Participation | Total |
|---|---|---|---|
| Win | 400 (`FUT_MATCH_COINS_WIN`) | 0 (`FUT_MATCH_PARTICIPATION`) | 400 |
| Draw | 200 (`FUT_MATCH_COINS_DRAW`) | 0 | 200 |
| Loss | 100 (`FUT_MATCH_COINS_LOSS`) | 0 | 100 |
`allCoins` = post-credit balance; `gameModeAward.coins` = per-result + participation.
## Ownership split (Rust vs Python)
The match loop is **partially migrated**. Only the coin-crediting END leg is
economy state, so only it is Rust/Core-owned; the CREATE/READY/PLAY legs are still
served by the Python oracle.
| Call | Owner | Where |
|---|---|---|
| CREATE | Python | `utas_server.py::match_route` (via host `Route::Passthrough`) |
| READY | Python | `utas_server.py::match_route` |
| PLAY | Python | `utas_server.py::match_route` |
| END (reward) | **Rust/Core** (on `POST /ut/delete/game/<sku>/match`) | `EconomyRoute::MatchEnd``handle_match_end``build_match_reward_body`; outcome via `economy_policy::match_result_from_reason`, coins via `match_result_coins`/`match_reward_total`. 503 on Core error, never Python. Python also accepts `/ut/game/<sku>/match/end`. |
END is classified in `classify_economy` (`openfut-utas-host/src/lib.rs`) and dispatched
by the economy authority barrier ahead of the general classifier — so it can never
also reach the Python passthrough (NEVER-BOTH). The Rust END writes the coin reward
through the single Core economy transaction (`grant_reward`); W/D/L record + match
count still live in the Python save until CREATE/READY/PLAY migrate.
## Test coverage
`fifa17-recon/tools/test_match_lifecycle.py` (PROVEN, isolated — temp profile, no
live server): drives CREATE→READY→PLAY→END and asserts:
- CREATE returns `reportIdEnabled:false` + advances `nextItemId` by 1.
- READY returns exactly `{matchId, opponentPersonaId:0}`.
- PLAY returns `{}` and does **not** advance `nextItemId`.
- END credits `MATCH_COINS["won"] + MATCH_PARTICIPATION`, persists
`record == {won:1,draw:0,loss:0}` and `matchesPlayed == 1`.
## Open questions / blockers
1. **READY `items` contract (opponent squad)** — the nested `items` member of
`FutMatchReadyServerResponse` is unserved pending a live capture. Whether the
client requires it present/non-empty to enter a match is unknown. **Blocker.**
2. **Client mode-entry gate** — reaching a match from the FUT hub UI is unverified;
`FUT_MODES` stays **off by default** (the season/tournament routes return `{}`).
3. **No in-game acceptance** — every claim here is static-analysis + isolated replay.
The first live match is expected to reveal whether the static read was complete
(every match body is logged for exactly this reason).
4. **Football simulation** — the actual gameplay (Blaze game-server) is out of scope
here and unverified.
## Sources
- `docs/PROJECT_STATE.md` (match lifecycle status), `docs/direction.md` (Tier-2 loop).
- `fifa17-recon/tools/utas_server.py`: `_match_call` (3325), `match_ready_body` (3346),
`match_route` (3357), `destroy_match_body` (3281), `_match_result` (3236),
`MATCH_COINS`/`MATCH_PARTICIPATION` (3201), `_END_REASON` (3229); CardsDLL RPC
descriptor block (3183-3197).
- `openfut-adapter-fifa17/src/fut/economy_policy.rs` (reward policy + outcome map).
- `openfut-utas-host/src/lib.rs`: `EconomyRoute::MatchEnd`, `handle_match_end`,
`build_match_reward_body`.
- `fifa17-recon/tools/test_match_lifecycle.py` (lifecycle regression).
- Vault: `02 Reverse Engineering/FIFA 17/Protocol Findings.md`,
`06 Agent Memory/{Project State,Known Issues}.md`.
+34 -20
View File
@@ -1,4 +1,9 @@
# Production Authority Matrix (post-P1)
# Production Authority Matrix (post-P1 -> P2-routes promoted 2026-08-17)
> **P2-routes promoted to production 2026-08-17.** prod-host swapped P1 `e5be8730` -> post-P1
> `fda40d12`; catalog `35a0913b` -> `9f6addaa` (resolves all owned assets, dropped_no_asset=0).
> Every previously-Python non-economy route now RUST in prod (OBSERVED prod log). Rollback hot:
> restore P1 binary + backup catalog (`economy-2026-08-17-p2/backup/`).
Definitive inventory of every production-reachable FIFA17 route/service and its
current owner. Derived from the live `prod-host` dispatch log (owner= labels,
@@ -28,29 +33,38 @@ Legend: owner R = Rust/Core, P = Python oracle (:8199 proxied via PYTHON_FALLBAC
| /trade/<id> (POST/PUT/GET) | R | coins,inv,listings | handle_market_buy | NO |
| DELETE /ut/delete/../trade/<id> | R | listings | handle_market_cancel | NO |
### NON-ECONOMY — Rust-owned
### NON-ECONOMY — Rust-owned (route migration 2026-08-17, OBSERVED prod log)
| Route | Owner | Notes |
|---|---|---|
| POST /ut/auth | R (OBSERVE) | session_opened; envelope currently Python-generated, Rust observes — auth boundary TBD |
| POST /ut/auth (+ /ut/delete/auth) | R | Rust mints the SID (OPENFUT-SID-{:016X}); opens Rust session; adopts persona from body. No Python. |
| POST /openfut/account/sync | R | full Rust envelope; coins/unopenedPacks from Core; clubName OpenFUT / clubAbbr OFC constants |
| GET /userMassInfo | R | FULL Rust envelope (userInfo+squad+settings+pileSizeClientData); no Python. coins from Core, squad == /squad/active |
| GET/PUT /clientdata/<key> | R | host ClientDataStore (JSON-persisted); PUT acks {}, GET returns blob or {} |
| capability (/openfut/fifa17/capability) | R | -> Bound (CleanV1) |
| GET /userMassInfo | R (OVERLAY) | Rust overlays economy fields + squad onto Python envelope (hybrid) |
| GET /club, /club/* readers | R | Core-backed collection |
| squad-active, /squad/* | R | Core squad tx (SquadReplace) |
| GET /club, /club/* readers | R | Core-backed collection (dropped_no_asset=0) |
| GET /squad/0, /squad/active, /squad/list; PUT /squad/<n> | R | Core squad projection + tx; GET /squad/0 == active squad (verified structurally identical) |
| GET /user/accountinfo | R | {} |
| GET /user | R | `{"userInfo": …}` — same userInfo builder as userMassInfo (shared); squad rating is Core-authoritative (DIFFERENT-BY-DESIGN vs Python's stale value) |
| GET /settings | R | {"configs":[]} |
| GET /leaderboards/options | R | {} |
| PUT /match/reset | R | {} |
| GET /phishing/trusteddevice | R | security-question stateless ack |
| GET /hub | R | Core-derived counts |
| GET /club/stats/{year,consumables,staff,country,league,team} | R | Core aggregation; context buckets keyed nation/league/team (owned=1982); staff={} |
| GET /store, /match/keepalive, /captcha, /tfa, /livemessage, /activeMessage | R | unconditional constant acks (byte-identical to the oracle; StaticAck route) |
| GET /watchList (+ PUT/POST/DELETE) | R | empty watch list + authoritative Core credits; add/remove is a no-op ack (oracle persists none) |
| POST /ut/.../match/end (DestroyMatch) | R | economy reward (coins credited via Core grant_reward) |
### NON-ECONOMY — still Python (PYTHON_FALLBACK, OBSERVED live)
| Method/path | Owner | Response (observed) | Stateful | Migration target |
|---|---|---|---|---|
| POST /openfut/account/sync | P | account envelope | selects profile | R (candidate) |
| GET /user/accountinfo | P | {} | no (static) | R (trivial) |
| GET /settings | P | {"configs":[]} | no (static) | R (trivial) |
| GET /phishing/trusteddevice | P | trusted-device/security-question | first-entry state | R (security-question priority) |
| PUT /match/reset | P | {} | maybe | R (trivial/small) |
| GET /hub | P | tile counts (clubPlayers/auction/tradePile) | reads inventory+listings | R (reads Core) |
| GET /club/stats/year | P | stat[] (players/gold/...) | reads inventory | R (reads Core) |
| GET /club/stats/consumables | P | stat[] | reads inventory | R (reads Core) |
| GET /club/stats/staff | P | {} | no | R (trivial/reads Core) |
| GET /leaderboards/options | P | {} | no (static) | R (trivial) |
| PUT /clientdata/userHubData | P | {} | stores blob | R (small stateful) |
### NON-ECONOMY — still Python (PYTHON_FALLBACK)
| Method/path | Owner | Reason |
|---|---|---|
| GET /item/resource, /defid | P | `item_def(rid)` shapes `{itemData:[…]}` from `PLAYER_DEFS` (asset=rid&0xffffff → name/rating/pos/attrs) + consumable defs, placeholder fallback ("Player",75,attrs 70). Faithful Rust needs those def tables in the host. Shape captured: `docs/evidence/route-shapes-2026-08-17/defs_resource.json` |
| GET /clubUser, /user/list, /user/club | P | club identity (off→{}, flag-gated) + rename (mutating); Core has `clubs` but rename needs a Core write |
| GET /squad/<n> (n≠0, non-active) | P | no multi-squad Core model (Rust owns squad/0, squad/active, /squad/list, PUT) |
| /squad/mode/draft/* | P | FUT Draft mode |
| GET /season, /tournament, /champion, /leaderboards, /sbs/* | P | mode-gated (FUT_MODES/_SBC off by default → `{}`); real behavior needs the mode logic ported |
| GET /marketdata (+ /marketdata/pricelimits) | P | suggested pricing; freeze-risk (array vs object container); Python-correct, constant band 150..15000 |
| POST /ut/.../match (CREATE), /match/ready (READY), /match (PLAY) | P | match handshake legs; no match ever played in-game (see docs/MATCH_LIFECYCLE.md) |
## AUXILIARY SERVICES (prod container OPENFUT_SERVERS="blaze roster pow")
All aux services are **Python in production today** (container `entrypoint.sh` runs
+6 -3
View File
@@ -8,7 +8,7 @@ rollback-to-python-p2.sh, :p2-rollback image b1b929953f, profile 39bb3e83).
## A. LIVE PRODUCTION REQUIRED (still owns behavior in the live flow)
| Component | Role | Rust status | Retire when |
|---|---|---|---|
| oracle utas_server.py (:8199) NON-ECONOMY | account/sync, hub, club/stats, clientdata, userMassInfo envelope | 5 routes migrated (accountinfo/settings/leaderboards-options/match-reset/phishing); hub/club-stats/clientdata/account-sync still proxied | remaining non-economy routes migrated + operator-validated |
| oracle utas_server.py (:8199) NON-ECONOMY | **remaining**: item-defs (item/resource,defid), user-identity (user,clubUser,user/list,user/club), non-active squad/<n>, draft, watchList, marketdata, mode-gated (season/tournament/champion/leaderboards/sbs, off→{}) | **Most non-economy migrated 2026-08-17** (account/sync, auth, userMassInfo, clientdata, hub, club/stats/*, settings, accountinfo, phishing, match/reset, leaderboards/options, static acks — all Rust). See PRODUCTION_AUTHORITY_MATRIX | remaining tail migrated (needs live captures for the data routes; mode-logic port for the gated ones) OR mode-gated ones kept Python |
| blaze_responder_v3b.py (:42130 Blaze) | FUT Blaze transport | Rust openfut-blaze-host COMPLETE, gate-proven (Gate 10) | container cutover (operator-gated deploy) |
| blaze_responder_v3b.py (:42127 redirector TLS) | first-hop TLS redirect | Rust openfut-redirector-host COMPLETE (OpenSSL) | container cutover + cert consistency |
| roster_server.py (:8081) | roster-update XML | Rust openfut-roster-host COMPLETE (unit-only) | container cutover |
@@ -33,8 +33,11 @@ rollback-to-python-p2.sh, :p2-rollback image b1b929953f, profile 39bb3e83).
- fifa-blaze (FIFA23 capture stub), openfut-bridge (FIFA23): retired lineage, not in FIFA17 flow.
## Retirement gating
1. Migrate remaining non-economy oracle routes to Rust (hub/club-stats/clientdata/account-sync) — then oracle
is oracle-only (class B).
1. **Mostly DONE (2026-08-17)**: account/sync, ut/auth (Rust SID mint), userMassInfo (full), clientdata,
club/stats/{country,league,team}, and the trivial static acks migrated + deployed. **Remaining on Python**:
item-defs, user-identity (user/clubUser/user-list/user-club), non-active squad/<n>, draft, watchList,
marketdata (no captured wire shape), and mode-gated season/tournament/champion/leaderboards/sbs
(return {} while FUT_MODES/_SBC off). Migrate the data ones once live shapes are captured.
2. Deploy Rust blaze/roster/redirector via container cutover (operator-gated) — then those Python responders
are class D/E only.
3. POW: build Rust host + reverse bodies (largest blocker) OR keep Python POW as class A indefinitely.
@@ -0,0 +1,67 @@
{
"champion": {
"bytes": 2,
"path": "/ut/game/fifa17/champion",
"status": 200
},
"clubUser": {
"bytes": 2,
"path": "/ut/game/fifa17/clubUser",
"status": 200
},
"defid": {
"bytes": 600,
"path": "/ut/game/fifa17/defid?definitionId=200389",
"status": 200
},
"defs_resource": {
"bytes": 600,
"path": "/ut/game/fifa17/item/resource?resourceId=200389",
"status": 200
},
"draft_state": {
"bytes": 118,
"path": "/ut/game/fifa17/squad/mode/draft/state",
"status": 200
},
"marketdata": {
"bytes": 110,
"path": "/ut/game/fifa17/marketdata/pricelimits?defId=200389,200104",
"status": 200
},
"sbs_sets": {
"bytes": 537,
"path": "/ut/game/fifa17/sbs/sets",
"status": 200
},
"season": {
"bytes": 2,
"path": "/ut/game/fifa17/season",
"status": 200
},
"squad_0": {
"bytes": 7792,
"path": "/ut/game/fifa17/squad/0",
"status": 200
},
"tournament": {
"bytes": 2,
"path": "/ut/game/fifa17/tournament",
"status": 200
},
"user": {
"bytes": 751,
"path": "/ut/game/fifa17/user",
"status": 200
},
"user_list": {
"bytes": 2,
"path": "/ut/game/fifa17/user/list",
"status": 200
},
"watchList": {
"bytes": 52,
"path": "/ut/game/fifa17/watchList",
"status": 200
}
}
@@ -0,0 +1 @@
{}
@@ -0,0 +1 @@
{}
@@ -0,0 +1,53 @@
{
"itemData": [
{
"assetId": 200389,
"attributeList": [
{
"index": 0,
"value": 70
},
{
"index": 1,
"value": 70
},
{
"index": 2,
"value": 70
},
{
"index": 3,
"value": 70
},
{
"index": 4,
"value": 70
},
{
"index": 5,
"value": 70
}
],
"cardType": 0,
"cardassetid": 200389,
"cardsubtypeid": 0,
"commodityId": 200389,
"commonName": "Player",
"definitionId": 200389,
"id": 200389,
"itemState": "free",
"itemType": "player",
"lastName": "Player",
"leagueId": 0,
"name": "Player",
"nation": 0,
"playStyle": 250,
"preferredPosition": "CM",
"rareflag": 1,
"rating": 75,
"resourceId": 200389,
"teamid": 0,
"untradeable": true
}
]
}
@@ -0,0 +1,53 @@
{
"itemData": [
{
"assetId": 200389,
"attributeList": [
{
"index": 0,
"value": 70
},
{
"index": 1,
"value": 70
},
{
"index": 2,
"value": 70
},
{
"index": 3,
"value": 70
},
{
"index": 4,
"value": 70
},
{
"index": 5,
"value": 70
}
],
"cardType": 0,
"cardassetid": 200389,
"cardsubtypeid": 0,
"commodityId": 200389,
"commonName": "Player",
"definitionId": 200389,
"id": 200389,
"itemState": "free",
"itemType": "player",
"lastName": "Player",
"leagueId": 0,
"name": "Player",
"nation": 0,
"playStyle": 250,
"preferredPosition": "CM",
"rareflag": 1,
"rating": 75,
"resourceId": 200389,
"teamid": 0,
"untradeable": true
}
]
}
@@ -0,0 +1,9 @@
[
{
"gamesWonCurrentMatch": 0,
"roundsInfo": [],
"squadState": "INVALID",
"stateParam1": "INVALID",
"stateParam2": "0"
}
]
@@ -0,0 +1,12 @@
[
{
"defId": 200389,
"maxPrice": 15000,
"minPrice": 150
},
{
"defId": 200104,
"maxPrice": 15000,
"minPrice": 150
}
]
@@ -0,0 +1,35 @@
{
"categories": [
{
"categoryId": 1,
"name": "Foundations",
"priority": 1,
"sets": [
{
"awards": [],
"categoryId": 1,
"challengesCompletedCount": 0,
"challengesCount": 1,
"description": "Submit an 11-player squad.",
"endTime": 4102444800,
"hidden": false,
"name": "Bronze Challenge",
"priority": 1,
"setId": 1
},
{
"awards": [],
"categoryId": 1,
"challengesCompletedCount": 0,
"challengesCount": 1,
"description": "Get started with your first SBC.",
"endTime": 4102444800,
"hidden": false,
"name": "Simple Start",
"priority": 2,
"setId": 2
}
]
}
]
}
@@ -0,0 +1 @@
{}
@@ -0,0 +1,707 @@
{
"actives": [],
"captain": 100000001,
"changed": 0,
"chemistry": 49,
"custom": "[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,50,50,0,50,40,65,0,65,50,50,1]",
"formation": "f433",
"id": 0,
"kicktakers": [
{
"dream": false,
"id": 100000001,
"index": 0
},
{
"dream": false,
"id": 100000001,
"index": 1
},
{
"dream": false,
"id": 100000001,
"index": 2
},
{
"dream": false,
"id": 100000001,
"index": 3
},
{
"dream": false,
"id": 100000001,
"index": 4
}
],
"manager": [
{
"dream": false,
"id": 100000427
}
],
"personaId": 33068179,
"players": [
{
"index": 0,
"itemData": {
"assetId": 200389,
"attributeList": [
{
"index": 0,
"value": 83
},
{
"index": 1,
"value": 90
},
{
"index": 2,
"value": 77
},
{
"index": 3,
"value": 82
},
{
"index": 4,
"value": 50
},
{
"index": 5,
"value": 87
}
],
"cardassetid": 200389,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 200389,
"fitness": 99,
"id": 100000003,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 44,
"owners": 1,
"playStyle": 250,
"preferredPosition": "GK",
"rareflag": 1,
"rating": 87,
"resourceId": 200389,
"teamid": 240,
"untradeable": true
},
"kitNumber": 1
},
{
"index": 1,
"itemData": {
"assetId": 197445,
"attributeList": [
{
"index": 0,
"value": 86
},
{
"index": 1,
"value": 73
},
{
"index": 2,
"value": 81
},
{
"index": 3,
"value": 83
},
{
"index": 4,
"value": 83
},
{
"index": 5,
"value": 73
}
],
"cardassetid": 197445,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 197445,
"fitness": 99,
"id": 100000006,
"itemState": "free",
"itemType": "player",
"leagueId": 19,
"nation": 4,
"owners": 1,
"playStyle": 250,
"preferredPosition": "LB",
"rareflag": 1,
"rating": 87,
"resourceId": 197445,
"teamid": 21,
"untradeable": true
},
"kitNumber": 4
},
{
"index": 2,
"itemData": {
"assetId": 155862,
"attributeList": [
{
"index": 0,
"value": 78
},
{
"index": 1,
"value": 63
},
{
"index": 2,
"value": 70
},
{
"index": 3,
"value": 70
},
{
"index": 4,
"value": 87
},
{
"index": 5,
"value": 83
}
],
"cardassetid": 155862,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 155862,
"fitness": 99,
"id": 100000005,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 45,
"owners": 1,
"playStyle": 250,
"preferredPosition": "CB",
"rareflag": 1,
"rating": 89,
"resourceId": 155862,
"teamid": 243,
"untradeable": true
},
"kitNumber": 3
},
{
"index": 3,
"itemData": {
"assetId": 182521,
"attributeList": [
{
"index": 0,
"value": 45
},
{
"index": 1,
"value": 80
},
{
"index": 2,
"value": 88
},
{
"index": 3,
"value": 79
},
{
"index": 4,
"value": 69
},
{
"index": 5,
"value": 70
}
],
"cardassetid": 182521,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 182521,
"fitness": 99,
"id": 100000008,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 21,
"owners": 1,
"playStyle": 250,
"preferredPosition": "CM",
"rareflag": 1,
"rating": 88,
"resourceId": 182521,
"teamid": 243,
"untradeable": true
},
"kitNumber": 6
},
{
"index": 4,
"itemData": {
"assetId": 189332,
"attributeList": [
{
"index": 0,
"value": 93
},
{
"index": 1,
"value": 69
},
{
"index": 2,
"value": 75
},
{
"index": 3,
"value": 83
},
{
"index": 4,
"value": 81
},
{
"index": 5,
"value": 75
}
],
"cardassetid": 189332,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 189332,
"fitness": 99,
"id": 100000007,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 45,
"owners": 1,
"playStyle": 250,
"preferredPosition": "LB",
"rareflag": 1,
"rating": 86,
"resourceId": 189332,
"teamid": 241,
"untradeable": true
},
"kitNumber": 5
},
{
"index": 5,
"itemData": {
"assetId": 158023,
"attributeList": [
{
"index": 0,
"value": 89
},
{
"index": 1,
"value": 90
},
{
"index": 2,
"value": 86
},
{
"index": 3,
"value": 96
},
{
"index": 4,
"value": 26
},
{
"index": 5,
"value": 61
}
],
"cardassetid": 158023,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 158023,
"fitness": 99,
"id": 100000002,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 52,
"owners": 1,
"playStyle": 250,
"preferredPosition": "RW",
"rareflag": 1,
"rating": 93,
"resourceId": 158023,
"teamid": 241,
"untradeable": true
},
"kitNumber": 9
},
{
"index": 6,
"itemData": {
"assetId": 183907,
"attributeList": [
{
"index": 0,
"value": 79
},
{
"index": 1,
"value": 50
},
{
"index": 2,
"value": 72
},
{
"index": 3,
"value": 68
},
{
"index": 4,
"value": 90
},
{
"index": 5,
"value": 85
}
],
"cardassetid": 183907,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 183907,
"fitness": 99,
"id": 100000004,
"itemState": "free",
"itemType": "player",
"leagueId": 19,
"nation": 21,
"owners": 1,
"playStyle": 250,
"preferredPosition": "CB",
"rareflag": 1,
"rating": 90,
"resourceId": 183907,
"teamid": 21,
"untradeable": true
},
"kitNumber": 2
},
{
"index": 7,
"itemData": {
"assetId": 183277,
"attributeList": [
{
"index": 0,
"value": 90
},
{
"index": 1,
"value": 81
},
{
"index": 2,
"value": 82
},
{
"index": 3,
"value": 91
},
{
"index": 4,
"value": 32
},
{
"index": 5,
"value": 64
}
],
"cardassetid": 183277,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 183277,
"fitness": 99,
"id": 100000009,
"itemState": "free",
"itemType": "player",
"leagueId": 13,
"nation": 7,
"owners": 1,
"playStyle": 250,
"preferredPosition": "LM",
"rareflag": 1,
"rating": 88,
"resourceId": 183277,
"teamid": 5,
"untradeable": true
},
"kitNumber": 7
},
{
"index": 8,
"itemData": {
"assetId": 176580,
"attributeList": [
{
"index": 0,
"value": 82
},
{
"index": 1,
"value": 90
},
{
"index": 2,
"value": 79
},
{
"index": 3,
"value": 87
},
{
"index": 4,
"value": 42
},
{
"index": 5,
"value": 79
}
],
"cardassetid": 176580,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 176580,
"fitness": 99,
"id": 100000010,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 60,
"owners": 1,
"playStyle": 250,
"preferredPosition": "ST",
"rareflag": 1,
"rating": 92,
"resourceId": 176580,
"teamid": 241,
"untradeable": true
},
"kitNumber": 10
},
{
"index": 9,
"itemData": {
"assetId": 188545,
"attributeList": [
{
"index": 0,
"value": 81
},
{
"index": 1,
"value": 87
},
{
"index": 2,
"value": 74
},
{
"index": 3,
"value": 85
},
{
"index": 4,
"value": 38
},
{
"index": 5,
"value": 82
}
],
"cardassetid": 188545,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 188545,
"fitness": 99,
"id": 100000025,
"itemState": "free",
"itemType": "player",
"leagueId": 19,
"nation": 37,
"owners": 1,
"pile": "club",
"playStyle": 250,
"preferredPosition": "ST",
"rareflag": 1,
"rating": 90,
"resourceId": 188545,
"teamid": 21,
"untradeable": true
},
"kitNumber": 11
},
{
"index": 10,
"itemData": {
"assetId": 20801,
"attributeList": [
{
"index": 0,
"value": 92
},
{
"index": 1,
"value": 92
},
{
"index": 2,
"value": 81
},
{
"index": 3,
"value": 91
},
{
"index": 4,
"value": 33
},
{
"index": 5,
"value": 80
}
],
"cardassetid": 20801,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 20801,
"fitness": 99,
"id": 100000001,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 38,
"owners": 1,
"playStyle": 250,
"preferredPosition": "LW",
"rareflag": 1,
"rating": 94,
"resourceId": 20801,
"teamid": 243,
"untradeable": true
},
"kitNumber": 8
},
{
"index": 11,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 12,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 13,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 14,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 15,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 16,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 17,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 18,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 19,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 20,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 21,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 22,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
}
],
"rating": 90,
"squadName": "OpenFUT",
"squadType": "REGULAR_SQUAD",
"starRating": 90
}
@@ -0,0 +1 @@
{}
@@ -0,0 +1,54 @@
{
"userInfo": {
"accountCreatedPlatformName": "pc",
"actives": [],
"bidTokens": {
"count": 0,
"updateTime": 0
},
"clubAbbr": "OFC",
"clubName": "OpenFUT",
"clubNameChangeAllowed": false,
"currencies": [
{
"active": true,
"finalFunds": 29876776,
"funds": 29876776,
"name": "coins"
},
{
"active": true,
"finalFunds": 0,
"funds": 0,
"name": "points"
}
],
"divisionOffline": 10,
"divisionOnline": 10,
"draw": 0,
"established": "2026",
"feature": {},
"loss": 0,
"personaId": 33068179,
"purchased": false,
"reliability": {
"matchUnfinishedTime": 0,
"reliability": 100
},
"sessionCoinsBankBalance": 0,
"squadList": {
"squad": [
{
"chemistry": 49,
"formation": "f433",
"id": 0,
"rating": 89,
"squadName": "OpenFUT",
"squadType": "REGULAR_SQUAD"
}
]
},
"trophies": 0,
"won": 0
}
}
@@ -0,0 +1 @@
{}
@@ -0,0 +1,5 @@
{
"auctionInfo": [],
"credits": 29876776,
"total": 0
}
+95 -26
View File
@@ -23,8 +23,8 @@ use serde_json::{json, Value};
use crate::fut::content_taxonomy::{consumable_family, ContentKind};
/// One owned item, already classified from the catalog + entity tables by the
/// host. `subtype`/`rare` come from the FIFA catalog; `nation_id` from the
/// reverse entity resolver (None = unresolved nation, bucket skipped).
/// host. `subtype`/`rare` come from the FIFA catalog; `nation_id`/`league_id`/
/// `team_id` from the reverse entity resolver (None = unresolved, bucket skipped).
#[derive(Debug, Clone)]
pub struct ClubStatInput {
pub kind: ContentKind,
@@ -32,6 +32,20 @@ pub struct ClubStatInput {
pub rating: i64,
pub rare: bool,
pub nation_id: Option<i64>,
pub league_id: Option<i64>,
pub team_id: Option<i64>,
}
/// Which entity the per-context (`contextId 3`) buckets are keyed by — the FIFA
/// `MY CLUB` sub-screen selector (`fut_club_stats.py::context_rows`):
/// * `Nation` — the default screen (year/consumables/club/newcards): nation buckets.
/// * `League` — URL `club/stats/country/<id>`: league (leagueId) buckets, tier stats.
/// * `Team` — URL `club/stats/league/<id>`: team (teamid) buckets, players/kits/badge.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContextField {
Nation,
League,
Team,
}
// Stat ids (CardsDLL atom table, fut_club_stats.py VOCAB).
@@ -137,9 +151,10 @@ fn is_player(i: &ClubStatInput) -> bool {
matches!(i.kind, ContentKind::Player)
}
/// Build the full `{"stat":[…]}` body for club/stats/{year,consumables} — the
/// global bucket plus per-nation buckets.
pub fn club_stats_body(items: &[ClubStatInput]) -> Value {
/// Build the full `{"stat":[…]}` body for a club/stats screen — the global bucket
/// (identical for every mode) plus per-context buckets keyed by `ctx`
/// (nation / league / team), mirroring `fut_club_stats.py::stats_body`.
pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
// ---- global bucket (contextId 1, contextValue 0), sorted by stat id ----
let mut g: BTreeMap<i64, i64> = BTreeMap::new();
let players: Vec<&ClubStatInput> = items.iter().filter(|i| is_player(i)).collect();
@@ -209,25 +224,37 @@ pub fn club_stats_body(items: &[ClubStatInput]) -> Value {
let mut stat: Vec<Value> = g.iter().map(|(sid, v)| row(1, 0, *sid, *v)).collect();
// ---- per-nation buckets (contextId 3, contextValue = nation id) ----
let mut by_nation: BTreeMap<i64, Vec<&ClubStatInput>> = BTreeMap::new();
// ---- per-context buckets (contextId 3, contextValue = entity id) ----
// Nation/League read the tier set (gold/silver/bronze/rare/kits/badges);
// Team (the league screen) reads players/kits/badgeDBid. Mirrors context_rows.
let mut by_ctx: BTreeMap<i64, Vec<&ClubStatInput>> = BTreeMap::new();
for p in &players {
if let Some(nid) = p.nation_id {
by_nation.entry(nid).or_default().push(p);
let id = match ctx {
ContextField::Nation => p.nation_id,
ContextField::League => p.league_id,
ContextField::Team => p.team_id,
};
if let Some(id) = id {
by_ctx.entry(id).or_default().push(p);
}
}
for (nid, sel) in &by_nation {
let gold = sel.iter().filter(|i| i.rating >= 75).count() as i64;
let silver = sel.iter().filter(|i| (65..75).contains(&i.rating)).count() as i64;
let bronze = sel.iter().filter(|i| i.rating > 0 && i.rating < 65).count() as i64;
let rare = sel.iter().filter(|i| i.rare).count() as i64;
// Order mirrors the oracle context_rows: gold, silver, bronze, rare, kits, badges.
stat.push(row(3, *nid, S_GOLD, gold));
stat.push(row(3, *nid, S_SILVER, silver));
stat.push(row(3, *nid, S_BRONZE, bronze));
stat.push(row(3, *nid, S_RARE, rare));
stat.push(row(3, *nid, S_KITS, 0));
stat.push(row(3, *nid, S_BADGES, 0));
for (cid, sel) in &by_ctx {
if ctx == ContextField::Team {
stat.push(row(3, *cid, S_PLAYERS, sel.len() as i64));
stat.push(row(3, *cid, S_KITS, 0));
stat.push(row(3, *cid, 0x2E, 0)); // badgeDBid
} else {
let gold = sel.iter().filter(|i| i.rating >= 75).count() as i64;
let silver = sel.iter().filter(|i| (65..75).contains(&i.rating)).count() as i64;
let bronze = sel.iter().filter(|i| i.rating > 0 && i.rating < 65).count() as i64;
let rare = sel.iter().filter(|i| i.rare).count() as i64;
stat.push(row(3, *cid, S_GOLD, gold));
stat.push(row(3, *cid, S_SILVER, silver));
stat.push(row(3, *cid, S_BRONZE, bronze));
stat.push(row(3, *cid, S_RARE, rare));
stat.push(row(3, *cid, S_KITS, 0));
stat.push(row(3, *cid, S_BADGES, 0));
}
}
json!({ "stat": stat })
@@ -244,6 +271,8 @@ mod tests {
rating,
rare,
nation_id: nation,
league_id: None,
team_id: None,
}
}
fn staff(subtype: i64) -> ClubStatInput {
@@ -253,6 +282,8 @@ mod tests {
rating: 0,
rare: false,
nation_id: None,
league_id: None,
team_id: None,
}
}
fn consumable(subtype: i64) -> ClubStatInput {
@@ -262,6 +293,8 @@ mod tests {
rating: 0,
rare: false,
nation_id: None,
league_id: None,
team_id: None,
}
}
@@ -287,7 +320,7 @@ mod tests {
player(70, true, Some(52)),
player(60, false, Some(21)),
];
let g = global(&club_stats_body(&items));
let g = global(&club_stats_body(&items, ContextField::Nation));
assert_eq!(g["players"], 3);
assert_eq!(g["playersGold"], 1);
assert_eq!(g["playersSilver"], 1);
@@ -298,7 +331,7 @@ mod tests {
#[test]
fn staff_by_family() {
let items = vec![staff(6), staff(8), staff(8)]; // 1 gk coach, 2 fitness
let g = global(&club_stats_body(&items));
let g = global(&club_stats_body(&items, ContextField::Nation));
assert_eq!(g["staffGKCoach"], 1);
assert_eq!(g["staffFitnessCoach"], 2);
assert_eq!(g["staff"], 3);
@@ -314,7 +347,7 @@ mod tests {
consumable(217),
consumable(258),
];
let g = global(&club_stats_body(&items));
let g = global(&club_stats_body(&items, ContextField::Nation));
assert_eq!(g["consumables"], 4);
assert_eq!(g["consumablesTrainingGk"], 1);
assert_eq!(g["consumablesContractPlayer"], 1);
@@ -329,7 +362,7 @@ mod tests {
player(80, false, Some(52)),
staff(8),
];
let body = club_stats_body(&items);
let body = club_stats_body(&items, ContextField::Nation);
let g = global(&body);
assert_eq!(g["players"], 2, "staff not counted as player");
let buckets: Vec<&Value> = body["stat"]
@@ -346,7 +379,7 @@ mod tests {
#[test]
fn honest_zero_club_items_present() {
let g = global(&club_stats_body(&[player(90, false, None)]));
let g = global(&club_stats_body(&[player(90, false, None)], ContextField::Nation));
for atom in [
"stadia",
"balls",
@@ -358,4 +391,40 @@ mod tests {
assert_eq!(g[atom], 0, "{atom} present as honest zero");
}
}
#[test]
fn league_and_team_context_modes() {
let mut a = player(90, true, Some(52));
a.league_id = Some(13);
a.team_id = Some(240);
let mut b = player(60, false, Some(52));
b.league_id = Some(13);
b.team_id = Some(9);
let items = vec![a, b];
// country screen -> league (leagueId) buckets, tier set (6 rows).
let body = club_stats_body(&items, ContextField::League);
let league_rows: Vec<&Value> = body["stat"]
.as_array()
.unwrap()
.iter()
.filter(|r| r["contextId"] == 3 && r["contextValue"] == 13)
.collect();
assert_eq!(league_rows.len(), 6);
let gold = league_rows.iter().find(|r| r["type"] == "playersGold").unwrap();
assert_eq!(gold["typeValue"], 1);
// league screen -> team (teamid) buckets: players/kits/badgeDBid (3 rows).
let body = club_stats_body(&items, ContextField::Team);
let team_rows: Vec<&Value> = body["stat"]
.as_array()
.unwrap()
.iter()
.filter(|r| r["contextId"] == 3 && r["contextValue"] == 240)
.collect();
assert_eq!(team_rows.len(), 3);
let players = team_rows.iter().find(|r| r["type"] == "players").unwrap();
assert_eq!(players["typeValue"], 1);
assert!(team_rows.iter().any(|r| r["type"] == "badgeDBid"));
}
}
@@ -133,6 +133,176 @@ pub fn security_question_response(
}
}
// ─────────────────── POST /openfut/account/sync (Rust-owned) ─────────────────
/// The launcher `account/sync` request fields, with production defaults already
/// applied. Everything is optional in the wire body; missing fields fall back to
/// the fixed defaults the launcher expects. `personaId` defaults to the host's
/// configured persona (passed in), never a baked-in constant.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountSyncRequest {
pub persona_id: i64,
pub persona_name: String,
pub level: i64,
pub experience: i64,
pub experience_max: i64,
pub account_funds: i64,
pub account_funds_cap: i64,
}
/// Parse the `account/sync` request body, applying every default. `default_persona`
/// is the host's configured persona id (used when `personaId` is absent).
pub fn parse_account_sync(body: &[u8], default_persona: i64) -> AccountSyncRequest {
let v: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
let int = |key: &str, dflt: i64| v.get(key).and_then(Value::as_i64).unwrap_or(dflt);
let persona_name = v
.get("personaName")
.and_then(Value::as_str)
.unwrap_or("CAGE")
.to_string();
AccountSyncRequest {
persona_id: int("personaId", default_persona),
persona_name,
level: int("level", 1),
experience: int("experience", 0),
experience_max: int("experienceMax", 1000),
account_funds: int("accountFunds", 0),
account_funds_cap: int("accountFundsCap", 100000),
}
}
/// `POST /openfut/account/sync` — the launcher control-plane account summary.
/// `coins`/`unopened_packs` are the AUTHORITATIVE Core values (balance +
/// entitlement count), never Python's stale profile funds.
pub fn account_sync_body(req: &AccountSyncRequest, coins: i64, unopened_packs: usize) -> Value {
json!({
"account": {
"personaId": req.persona_id,
"personaName": req.persona_name,
"clubName": "OpenFUT",
"clubAbbr": "OFC",
"level": req.level,
"experience": req.experience,
"experienceMax": req.experience_max,
"accountFunds": req.account_funds,
"accountFundsCap": req.account_funds_cap,
"profilePath": "accounts/33068179/fifa17_profile.json",
"coins": coins,
"unopenedPacks": unopened_packs,
},
"status": "OK",
})
}
// ─────────────────────── GET …/userMassInfo (Rust-owned) ─────────────────────
/// Build the full `GET …/userMassInfo` body entirely in Rust (no Python).
///
/// `squad` is the Core-projected active squad (`user_mass_info_squad` output), so
/// it is byte-for-byte the object `GET …/squad/active` embeds. `coins` and
/// `unopened_packs` are the authoritative Core economy values. `userInfo.actives`
/// mirrors the squad's `actives` (capped at 5), and `userInfo.squadList` is the
/// summary of the current squad.
pub fn user_mass_info_body(
squad: Value,
coins: i64,
unopened_packs: usize,
persona_id: i64,
) -> Value {
let actives: Vec<Value> = squad
.get("actives")
.and_then(Value::as_array)
.map(|a| a.iter().take(5).cloned().collect())
.unwrap_or_default();
let squad_list = crate::fut::squad_projection::squad_list(&squad);
let mut user_info = json!({
"personaId": persona_id,
"clubName": "OpenFUT",
"clubAbbr": "OFC",
"established": "2026",
"accountCreatedPlatformName": "pc",
"currencies": [
{"name": "coins", "funds": coins, "finalFunds": coins, "active": true},
{"name": "points", "funds": 0, "finalFunds": 0, "active": true},
],
"won": 0,
"draw": 0,
"loss": 0,
"clubNameChangeAllowed": false,
"divisionOffline": 10,
"divisionOnline": 10,
"purchased": false,
"feature": {},
"reliability": {"reliability": 100, "matchUnfinishedTime": 0},
"bidTokens": {"count": 0, "updateTime": 0},
"trophies": 0,
"sessionCoinsBankBalance": 0,
"actives": actives,
"squadList": squad_list,
});
if unopened_packs > 0 {
user_info.as_object_mut().unwrap().insert(
"unopenedPacks".into(),
json!({"preOrderPacks": 0, "recoveredPacks": unopened_packs}),
);
}
json!({
"pileSizeClientData": {"entries": [{"key": 2, "value": 100}, {"key": 4, "value": 50}]},
"settings": {"configs": []},
"userData": {},
"squad": squad,
"userInfo": user_info,
})
}
// ───────────────────────────── POST /ut/auth (Rust) ──────────────────────────
/// Format `epoch_secs` (seconds since the Unix epoch) as UTC
/// `YYYY-MM-DD HH:MM:SS`. Pure civil-date arithmetic (Howard Hinnant's
/// `civil_from_days`), so no `time`/`chrono` dependency is needed.
pub fn format_utc_datetime(epoch_secs: i64) -> String {
let days = epoch_secs.div_euclid(86_400);
let secs_of_day = epoch_secs.rem_euclid(86_400);
let (hour, min, sec) = (secs_of_day / 3600, (secs_of_day % 3600) / 60, secs_of_day % 60);
// civil_from_days: days is a count of days since 1970-01-01.
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
let year = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let day = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
let month = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
let year = if month <= 2 { year + 1 } else { year };
format!("{year:04}-{month:02}-{day:02} {hour:02}:{min:02}:{sec:02}")
}
/// The persona a `/ut/auth` request adopts: `nucleusPersonaId` or `nuc` from the
/// body (numeric or numeric string), else `None` (the host substitutes its
/// configured persona). The client is never refused.
pub fn parse_auth_persona(body: &[u8]) -> Option<i64> {
let v: Value = serde_json::from_slice(body).ok()?;
let field = |key: &str| {
v.get(key).and_then(|x| {
x.as_i64()
.or_else(|| x.as_str().and_then(|s| s.parse::<i64>().ok()))
})
};
field("nucleusPersonaId").or_else(|| field("nuc"))
}
/// `POST /ut/auth` response body. `sid` is the freshly minted Rust session id;
/// `server_time` is UTC `YYYY-MM-DD HH:MM:SS` (also used for `lastOnlineTime`).
pub fn auth_body(sid: &str, server_time: &str) -> Value {
json!({
"protocol": 1,
"sid": sid,
"serverTime": server_time,
"lastOnlineTime": server_time,
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -310,4 +480,103 @@ mod tests {
security_question_response("GET", SecurityAction::Validate, true, DEV, None, Some(ANS));
assert_eq!(s, 405);
}
#[test]
fn account_sync_defaults_and_core_economy() {
// Empty body -> every default applied; persona falls back to the host's.
let req = parse_account_sync(b"", 33_068_179);
assert_eq!(req.persona_id, 33_068_179);
assert_eq!(req.persona_name, "CAGE");
assert_eq!(req.level, 1);
assert_eq!(req.experience_max, 1000);
assert_eq!(req.account_funds_cap, 100_000);
let body = account_sync_body(&req, 29_859_876, 2);
let acc = &body["account"];
assert_eq!(acc["clubName"], "OpenFUT");
assert_eq!(acc["clubAbbr"], "OFC");
assert_eq!(acc["profilePath"], "accounts/33068179/fifa17_profile.json");
assert_eq!(acc["coins"], 29_859_876);
assert_eq!(acc["unopenedPacks"], 2);
assert_eq!(body["status"], "OK");
}
#[test]
fn account_sync_honours_request_overrides() {
let req = parse_account_sync(
br#"{"personaId":42,"personaName":"X","level":9,"accountFunds":500}"#,
33_068_179,
);
assert_eq!(req.persona_id, 42);
assert_eq!(req.persona_name, "X");
assert_eq!(req.level, 9);
assert_eq!(req.account_funds, 500);
}
#[test]
fn user_mass_info_flat_shape() {
let squad = json!({
"id": 0,
"squadName": "OpenFUT",
"formation": "f433",
"squadType": "REGULAR_SQUAD",
"rating": 90,
"chemistry": 49,
"actives": [],
"players": [],
});
let body = user_mass_info_body(squad, 29_859_876, 0, 33_068_179);
// Flat top-level envelope.
assert_eq!(body["pileSizeClientData"]["entries"][0], json!({"key": 2, "value": 100}));
assert_eq!(body["pileSizeClientData"]["entries"][1], json!({"key": 4, "value": 50}));
assert_eq!(body["settings"], json!({"configs": []}));
assert_eq!(body["userData"], json!({}));
// userInfo economy + club identity.
let ui = &body["userInfo"];
assert_eq!(ui["personaId"], 33_068_179);
assert_eq!(ui["clubName"], "OpenFUT");
assert_eq!(ui["clubAbbr"], "OFC");
assert_eq!(ui["established"], "2026"); // string, not number
assert_eq!(ui["accountCreatedPlatformName"], "pc");
assert_eq!(ui["currencies"][0]["name"], "coins");
assert_eq!(ui["currencies"][0]["funds"], 29_859_876);
assert_eq!(ui["currencies"][0]["finalFunds"], 29_859_876);
assert_eq!(ui["currencies"][1]["name"], "points");
assert_eq!(ui["reliability"]["reliability"], 100);
assert_eq!(ui["divisionOnline"], 10);
assert!(ui.get("unopenedPacks").is_none(), "no packs -> key omitted");
assert_eq!(ui["squadList"]["squad"][0]["squadName"], "OpenFUT");
// Squad object embedded flat under top-level `squad`.
assert_eq!(body["squad"]["squadName"], "OpenFUT");
}
#[test]
fn user_mass_info_includes_unopened_packs_when_present() {
let squad = json!({"id": 0, "actives": [], "players": []});
let body = user_mass_info_body(squad, 100, 3, 33_068_179);
assert_eq!(body["userInfo"]["unopenedPacks"]["recoveredPacks"], 3);
assert_eq!(body["userInfo"]["unopenedPacks"]["preOrderPacks"], 0);
}
#[test]
fn utc_datetime_formats_known_epochs() {
// 2026-08-17 03:54:47 UTC == 1_786_938_887.
assert_eq!(format_utc_datetime(1_786_938_887), "2026-08-17 03:54:47");
// Unix epoch.
assert_eq!(format_utc_datetime(0), "1970-01-01 00:00:00");
}
#[test]
fn auth_persona_and_body() {
assert_eq!(
parse_auth_persona(br#"{"nucleusPersonaId":33068179}"#),
Some(33_068_179)
);
assert_eq!(parse_auth_persona(br#"{"nuc":"42"}"#), Some(42));
assert_eq!(parse_auth_persona(b"{}"), None);
let b = auth_body("OPENFUT-SID-DEADBEEF", "2026-08-17 03:54:47");
assert_eq!(b["protocol"], 1);
assert_eq!(b["sid"], "OPENFUT-SID-DEADBEEF");
assert_eq!(b["serverTime"], "2026-08-17 03:54:47");
assert_eq!(b["lastOnlineTime"], "2026-08-17 03:54:47");
}
}
+102
View File
@@ -0,0 +1,102 @@
//! Durable FIFA 17 **client-data blob** store (`clientdata` / `userHubData`).
//!
//! FIFA persists opaque per-user client blobs via `PUT/POST …/clientdata/<key>`
//! and reads them back via `GET …/clientdata/<key>`. The blobs are entirely
//! client-defined (UI/hub state) — the server only round-trips them and never
//! interprets their contents. This store keeps them in memory keyed by
//! `<persona>:<key>` and persists the whole map to a JSON file on every write, so
//! the client's saved state survives a host restart.
//!
//! The blobs are non-authoritative client presentation state (NOT economy or
//! ownership), so a missing/unreadable backing file starts empty rather than
//! being a hard failure.
use std::collections::HashMap;
use std::path::PathBuf;
use parking_lot::Mutex;
use serde_json::Value;
/// In-memory client-data blobs, persisted to a JSON file on write.
pub struct ClientDataStore {
path: PathBuf,
map: Mutex<HashMap<String, Value>>,
}
impl ClientDataStore {
/// Open the store, loading any previously-persisted blobs. A missing or
/// unreadable file starts empty.
pub fn open(path: impl Into<PathBuf>) -> Self {
let path = path.into();
let map = std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice::<HashMap<String, Value>>(&b).ok())
.unwrap_or_default();
ClientDataStore {
path,
map: Mutex::new(map),
}
}
fn compound_key(persona: i64, key: &str) -> String {
format!("{persona}:{key}")
}
/// The stored blob for `<persona>:<key>`, or `None` if never written.
pub fn get(&self, persona: i64, key: &str) -> Option<Value> {
self.map.lock().get(&Self::compound_key(persona, key)).cloned()
}
/// Store `value` under `<persona>:<key>` and persist the whole map to disk.
/// The serialized snapshot is taken under the lock; the file write happens
/// after the lock is released.
pub fn put(&self, persona: i64, key: &str, value: Value) {
let snapshot = {
let mut map = self.map.lock();
map.insert(Self::compound_key(persona, key), value);
serde_json::to_vec(&*map).unwrap_or_default()
};
if let Some(parent) = self.path.parent() {
if !parent.as_os_str().is_empty() {
let _ = std::fs::create_dir_all(parent);
}
}
let _ = std::fs::write(&self.path, snapshot);
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn temp_path(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"openfut-clientdata-test-{}-{}.json",
std::process::id(),
tag
))
}
#[test]
fn round_trips_and_persists_across_reopen() {
let path = temp_path("roundtrip");
let _ = std::fs::remove_file(&path);
let store = ClientDataStore::open(&path);
assert_eq!(store.get(33_068_179, "userHubData"), None);
store.put(33_068_179, "userHubData", json!({"tiles": [1, 2, 3]}));
assert_eq!(
store.get(33_068_179, "userHubData"),
Some(json!({"tiles": [1, 2, 3]}))
);
// A different persona under the same key is isolated.
assert_eq!(store.get(1, "userHubData"), None);
// Reopening reads the persisted blob back.
let reopened = ClientDataStore::open(&path);
assert_eq!(
reopened.get(33_068_179, "userHubData"),
Some(json!({"tiles": [1, 2, 3]}))
);
let _ = std::fs::remove_file(&path);
}
}
+23 -1
View File
@@ -34,6 +34,11 @@ pub struct HostConfig {
/// Durable FIFA17 item-pile metadata DB (host-owned SQLite). Required; must
/// survive host restart. Env `OPENFUT_PILE_DB`.
pub pile_db_path: String,
/// Durable client-data blob store (`clientdata`/`userHubData`), host-owned
/// JSON file. NOT required: defaults to env `OPENFUT_CLIENTDATA_DB`, else the
/// identity store's parent directory + `clientdata.json`. The blobs are
/// non-authoritative client UI state, so a default path is safe.
pub clientdata_path: String,
}
#[derive(Debug)]
@@ -68,6 +73,11 @@ fn required_i64_nonzero(key: &str) -> Result<i64, ConfigError> {
impl HostConfig {
pub fn from_env() -> Result<Self, ConfigError> {
let identity_store_path = required("OPENFUT_IDENTITY_STORE")?;
let clientdata_path = env::var("OPENFUT_CLIENTDATA_DB")
.ok()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| default_clientdata_path(&identity_store_path));
Ok(HostConfig {
listen_addr: required("OPENFUT_UTAS_HOST_ADDR")?,
python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?,
@@ -76,10 +86,22 @@ impl HostConfig {
tables_dir: env::var("OPENFUT_FIFA17_TABLES_DIR")
.unwrap_or_else(|_| "fifa17-recon/data/tables".into()),
catalog_path: required("OPENFUT_FIFA17_CATALOG")?,
identity_store_path: required("OPENFUT_IDENTITY_STORE")?,
persona_id: required_i64_nonzero("OPENFUT_PERSONA_ID")?,
market_db_path: required("OPENFUT_MARKET_DB")?,
pile_db_path: required("OPENFUT_PILE_DB")?,
identity_store_path,
clientdata_path,
})
}
}
/// Default client-data blob path: the identity store's parent directory +
/// `clientdata.json` (co-located with the other host-owned durable state).
fn default_clientdata_path(identity_store_path: &str) -> String {
std::path::Path::new(identity_store_path)
.parent()
.map(|p| p.join("clientdata.json"))
.unwrap_or_else(|| std::path::PathBuf::from("clientdata.json"))
.to_string_lossy()
.into_owned()
}
+316 -95
View File
@@ -35,6 +35,7 @@
//! `{"itemData":[]}` — the honest state until Core inventory is asset-backed.
pub mod async_bridge;
pub mod clientdata_store;
pub mod config;
pub mod economy_store;
pub mod market;
@@ -44,13 +45,13 @@ pub mod pile_store;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy};
use openfut_adapter_fifa17::fut::club_response::{
shape_club_response, CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats,
};
use openfut_adapter_fifa17::fut::club_stats::{club_stats_body, ClubStatInput};
use openfut_adapter_fifa17::fut::club_stats::{club_stats_body, ClubStatInput, ContextField};
use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind;
use openfut_adapter_fifa17::fut::economy_policy::{
match_reward_total, result_from_end_reason, MatchResult,
@@ -74,9 +75,10 @@ use openfut_adapter_fifa17::fut::store_session::{
validate_capability, SessionStore, StoreMode, SENTINEL_PACK_ID,
};
use openfut_identity::ExternalIdentityStore;
use rand::SeedableRng;
use rand::{Rng, SeedableRng};
use serde_json::{json, Value};
use clientdata_store::ClientDataStore;
use config::HostConfig;
// ───────────────────────────── Route classification ─────────────────────────
@@ -92,11 +94,18 @@ pub enum Route {
SquadList,
/// `GET …/squad/active` — the active squad object, projected from Core.
SquadActive,
/// `GET …/userMassInfo` — proxied to Python, with only `.squad` overlaid.
/// `GET …/userMassInfo` — served FULLY from Rust: the Core squad projection
/// plus the Rust/Core economy (coins + unopened packs). No Python.
UserMassInfo,
/// `POST /ut/auth` — proxied to Python (persona adoption + SID mint); the
/// returned `X-UT-SID` is observed to open a Rust session.
/// `POST /ut/auth` — Rust mints the `X-UT-SID`, adopts the persona from the
/// body (or the configured default), and opens a Rust session. Never proxied.
Auth,
/// `POST /openfut/account/sync` — launcher control-plane account summary,
/// served from Rust with authoritative Core coins/entitlements. Never Python.
AccountSync,
/// `GET/PUT/POST …/clientdata/<key>` — opaque per-user client blob store
/// (`userHubData` etc.), round-tripped through the Rust client-data store.
ClientData,
/// `POST /openfut/fifa17/capability` — launcher capability registration,
/// owned entirely in Rust (no economy, no proxy).
Capability,
@@ -125,6 +134,18 @@ pub enum Route {
/// Core-accurately in Rust (player tiers, staff/consumable families, nation
/// buckets). club/stats/staff stays a separate empty-set route.
ClubStats,
/// `GET …/store` (eligibility gate), `…/match/keepalive`, `…/captcha`,
/// `…/tfa`, `…/livemessage`, `…/activeMessage` — Rust-owned UNCONDITIONAL
/// static acks, byte-identical to the Python oracle's constant responses
/// (these are not flag-gated in the oracle, so a constant is exact parity).
StaticAck,
/// `GET …/watchList` — the transfer watch list, served empty from Rust with
/// authoritative Core credits (the oracle persists no watches; add/remove is a
/// no-op ack). Body: `{auctionInfo:[], credits, total:0}`.
WatchList,
/// `GET …/user` — the FUT user profile `{"userInfo": …}`, the same userInfo
/// object `userMassInfo` embeds (shared builder). POST /user (create) stays Python.
User,
/// Anything else — proxied verbatim to the Python oracle.
Passthrough,
}
@@ -143,10 +164,18 @@ pub fn classify(method: &str, path: &str) -> Route {
let get = method.eq_ignore_ascii_case("GET");
let put = method.eq_ignore_ascii_case("PUT");
let post = method.eq_ignore_ascii_case("POST");
// Session/capability vertical (Rust session authority; economy stays Python).
// Session/capability vertical (Rust session authority).
if post && path.starts_with("/ut/auth") {
return Route::Auth;
}
// Rust owns the /ut/delete/auth logout ack (any method).
if path.starts_with("/ut/delete/auth") {
return Route::Auth;
}
// Launcher control-plane account summary (not under /ut/game).
if post && path == "/openfut/account/sync" {
return Route::AccountSync;
}
if post && path == "/openfut/fifa17/capability" {
return Route::Capability;
}
@@ -156,17 +185,27 @@ pub fn classify(method: &str, path: &str) -> Route {
match ut_tail(path) {
Some("squad/list") if get => Route::SquadList,
Some("squad/active") if get => Route::SquadActive,
Some("squad/0") if get => Route::SquadActive,
Some("userMassInfo") if get => Route::UserMassInfo,
Some(tail) if tail.starts_with("clientdata/") => Route::ClientData,
Some(tail) if get && tail.starts_with("store/purchasegroup") => Route::StorePurchaseGroup,
Some(tail) if put && is_numeric_squad_tail(tail) => Route::SquadReplace,
Some("user/accountinfo") if get => Route::AccountInfo,
Some("user") if get => Route::User,
Some("settings") if get => Route::Settings,
Some("leaderboards/options") if get => Route::LeaderboardOptions,
Some("match/reset") if put => Route::MatchReset,
Some(tail) if tail.starts_with("phishing/") => Route::SecurityQuestion,
Some("club/stats/staff") if get => Route::ClubStatsStaff,
Some("club/stats/year") | Some("club/stats/consumables") if get => Route::ClubStats,
Some(t) if get && t.starts_with("club/stats/") => Route::ClubStats,
Some("hub") if get => Route::Hub,
Some("store") => Route::StaticAck,
Some("match/keepalive") => Route::StaticAck,
Some("captcha") if get => Route::StaticAck,
Some("tfa") => Route::StaticAck,
Some("livemessage") => Route::StaticAck,
Some("activeMessage") => Route::StaticAck,
Some("watchList") => Route::WatchList,
_ => Route::Passthrough,
}
}
@@ -1896,6 +1935,10 @@ pub struct Server {
/// [`Server::with_economy`]; the economy dispatch is inert without it, and
/// `handle_with_ip` does not consult it until the classifier barrier.
economy: Option<Arc<EconomyServices>>,
/// Durable per-user client-data blob store (`clientdata`/`userHubData`).
/// [`Server::new`] gives each instance an ephemeral temp-file store;
/// [`Server::from_config`] wires the configured durable path.
clientdata: Arc<ClientDataStore>,
}
impl Server {
@@ -1916,6 +1959,7 @@ impl Server {
sessions: Arc::new(Mutex::new(SessionStore::new())),
start: Instant::now(),
economy: None,
clientdata: Arc::new(ClientDataStore::open(ephemeral_clientdata_path())),
}
}
@@ -1972,6 +2016,7 @@ impl Server {
pool,
});
let clientdata = Arc::new(ClientDataStore::open(cfg.clientdata_path.clone()));
Ok(Server::new(
core,
entities,
@@ -1979,7 +2024,8 @@ impl Server {
Arc::new(PassClient::new(cfg.python_upstream.clone())),
cfg.persona_id,
)
.with_economy(economy))
.with_economy(economy)
.with_clientdata(clientdata))
}
/// Assemble the shared squad dependencies (Core access + the one production
@@ -2001,6 +2047,13 @@ impl Server {
self
}
/// Attach the durable client-data blob store (configured path). Kept separate
/// from construction so tests keep the ephemeral temp-file store.
pub fn with_clientdata(mut self, clientdata: Arc<ClientDataStore>) -> Self {
self.clientdata = clientdata;
self
}
/// Dispatch a FIFA17 economy route to its Rust handler, or `None` if the path
/// is not an economy route (or no economy services are wired). This is the
/// handler-wiring entry point exercised by the integration harness; it is
@@ -2243,37 +2296,10 @@ impl Server {
);
resp
}
Route::UserMassInfo => {
let deps = self.squad_deps();
let (mut resp, log) =
handle_user_mass_info(method, target, headers, body, &deps, self.pass.as_ref());
// Overlay the authoritative Core economy (coins + unopened-pack
// count) so NO stale Python economy value is visible post-barrier.
// userMassInfo remains a hybrid by design: Python supplies the
// non-economy envelope; Rust owns the squad AND the economy fields.
let mut econ_overlaid = false;
if let Some(svc) = &self.economy {
if (200..300).contains(&resp.status) {
if let (Ok(coins), Ok(ents)) = (svc.econ.balance(), svc.econ.entitlements())
{
if let Ok(mut root) = serde_json::from_slice::<Value>(&resp.body) {
if overlay_massinfo_economy(&mut root, coins, ents.len()) {
if let Ok(nb) = serde_json::to_vec(&root) {
set_json_body(&mut resp, nb);
econ_overlaid = true;
}
}
}
}
}
}
eprintln!(
"utas-host owner=RUST_OVERLAY route=userMassInfo status={} squad_outcome={} econ_overlaid={} detail=[{}]",
resp.status, log.outcome, econ_overlaid, log.detail
);
resp
}
Route::UserMassInfo => self.handle_user_mass_info_full(),
Route::Auth => self.handle_auth(method, target, headers, body, client_ip),
Route::AccountSync => self.handle_account_sync(body),
Route::ClientData => self.handle_client_data(method, path, body),
Route::Capability => self.handle_capability(body, client_ip),
Route::StorePurchaseGroup => {
self.handle_store_purchasegroup(method, target, headers, body, client_ip)
@@ -2299,7 +2325,10 @@ impl Server {
json_status(200, &non_economy::club_stats_staff_body())
}
Route::Hub => self.handle_hub(),
Route::ClubStats => self.handle_club_stats(),
Route::ClubStats => self.handle_club_stats(path),
Route::StaticAck => self.handle_static_ack(path),
Route::WatchList => self.handle_watchlist(method),
Route::User => self.handle_user(),
Route::SecurityQuestion => self.handle_security_question(method, target, headers),
Route::Passthrough => {
let resp = match self.pass.forward(method, target, headers, body) {
@@ -2330,43 +2359,157 @@ impl Server {
self.start.elapsed().as_secs_f64()
}
/// `POST /ut/auth` — proxy to Python (which mints the X-UT-SID, adopts the
/// persona and refreshes its save), then OBSERVE the returned SID to open a
/// Rust session bound to the peer IP + configured persona. Account/economy
/// stays Python-authoritative; Rust only tracks the session. Python's response
/// is returned byte-for-byte.
/// `POST /ut/auth` — Rust mints the `X-UT-SID`, adopts the persona from the
/// request body (`nucleusPersonaId`/`nuc`, else the configured persona) and
/// opens a Rust session bound to the peer IP. Never proxied to Python. The
/// SID is not an auth gate — only the Rust `SessionStore` consults it — so
/// minting it in Rust is complete. `/ut/delete/auth` is a `{}` logout ack.
fn handle_auth(
&self,
method: &str,
target: &str,
headers: &[(String, String)],
_headers: &[(String, String)],
body: &[u8],
client_ip: Option<&str>,
) -> WireResponse {
let resp = match self.pass.forward(method, target, headers, body) {
Ok(r) => r,
Err(e) => {
eprintln!("utas-host ERROR auth proxy to Python failed: {e}");
return error_response(502, "upstream_unavailable");
let path = target.split('?').next().unwrap_or(target);
if path.starts_with("/ut/delete/auth") {
eprintln!("utas-host owner=RUST route=auth-delete status=200");
return json_status(200, &json!({}));
}
let persona = non_economy::parse_auth_persona(body).unwrap_or(self.persona_id);
let sid = format!(
"OPENFUT-SID-{:016X}",
rand::rngs::StdRng::from_entropy().gen::<u64>()
);
self.sessions.lock().unwrap().open_session(
&sid,
client_ip.map(|s| s.to_string()),
persona,
self.now(),
);
let epoch_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let server_time = non_economy::format_utc_datetime(epoch_secs);
eprintln!(
"utas-host owner=RUST route=auth status=200 method={} ip={:?} persona={} sid_opened=true",
method, client_ip, persona
);
json_status(200, &non_economy::auth_body(&sid, &server_time))
}
/// `POST /openfut/account/sync` — launcher control-plane account summary. The
/// coins/unopened-pack counts are the AUTHORITATIVE Core economy (balance +
/// entitlements), NEVER Python's stale profile funds. Fail-closed 503 on any
/// Core error — never a Python fallback.
fn handle_account_sync(&self, body: &[u8]) -> WireResponse {
let svc = match &self.economy {
Some(s) => s,
None => {
eprintln!("utas-host owner=RUST route=account-sync status=503 error=no_economy");
return error_response(503, "core_unavailable");
}
};
let mut outcome = "no_sid";
if (200..300).contains(&resp.status) {
if let Some(sid) = observe_sid(&resp.body) {
self.sessions.lock().unwrap().open_session(
&sid,
client_ip.map(|s| s.to_string()),
self.persona_id,
self.now(),
let (coins, ents) = match (svc.econ.balance(), svc.econ.entitlements()) {
(Ok(c), Ok(e)) => (c, e),
_ => {
eprintln!("utas-host owner=RUST route=account-sync status=503 error=core");
return error_response(503, "core_unavailable");
}
};
let req = non_economy::parse_account_sync(body, self.persona_id);
eprintln!(
"utas-host owner=RUST route=account-sync status=200 persona={} coins={} packs={}",
req.persona_id,
coins,
ents.len()
);
json_status(200, &non_economy::account_sync_body(&req, coins, ents.len()))
}
/// `GET/PUT/POST …/clientdata/<key>` — opaque per-user client blob store. GET
/// returns the stored blob for `<persona>:<key>` (or `{}` if never written);
/// PUT/POST parse and store the body under the key and ALWAYS ack `{}`.
fn handle_client_data(&self, method: &str, path: &str, body: &[u8]) -> WireResponse {
let key = ut_tail(path)
.and_then(|t| t.strip_prefix("clientdata/"))
.unwrap_or("");
if method.eq_ignore_ascii_case("GET") {
let blob = self
.clientdata
.get(self.persona_id, key)
.unwrap_or_else(|| json!({}));
eprintln!("utas-host owner=RUST route=clientdata method=GET key={key} status=200");
json_status(200, &blob)
} else {
let val: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
self.clientdata.put(self.persona_id, key, val);
eprintln!(
"utas-host owner=RUST route=clientdata method={method} key={key} status=200 stored=true"
);
json_status(200, &json!({}))
}
}
/// Build the full Rust userMassInfo Value (userInfo + squad + settings +
/// pileSizeClientData) from Core, or a 503 response on Core error. Shared by
/// `GET …/userMassInfo` and `GET …/user` so both agree byte-for-byte.
fn build_user_mass_info(&self) -> Result<(Value, &'static str), WireResponse> {
let svc = match &self.economy {
Some(s) => s,
None => return Err(error_response(503, "core_unavailable")),
};
let (coins, ents) = match (svc.econ.balance(), svc.econ.entitlements()) {
(Ok(c), Ok(e)) => (c, e),
_ => return Err(error_response(503, "core_unavailable")),
};
let deps = self.squad_deps();
let (squad, squad_outcome) = match project_active_squad(&deps) {
HostProjection::Squad(v) => (user_mass_info_squad(v, self.persona_id), "ok"),
HostProjection::Stale => (empty_squad_overlay(self.persona_id), "stale_integrity"),
HostProjection::Missing => (empty_squad_overlay(self.persona_id), "missing_integrity"),
HostProjection::Error(_) => (empty_squad_overlay(self.persona_id), "core_error"),
};
Ok((
non_economy::user_mass_info_body(squad, coins, ents.len(), self.persona_id),
squad_outcome,
))
}
/// `GET …/userMassInfo` — served FULLY from Rust (no Python): the Core squad
/// projection (byte-identical to `GET …/squad/active`) plus the authoritative
/// Core economy (coins + unopened packs). Fail-closed 503 on Core error.
fn handle_user_mass_info_full(&self) -> WireResponse {
match self.build_user_mass_info() {
Ok((body, squad_outcome)) => {
eprintln!(
"utas-host owner=RUST route=userMassInfo status=200 squad_outcome={squad_outcome}"
);
outcome = "session_opened";
json_status(200, &body)
}
Err(e) => {
eprintln!("utas-host owner=RUST route=userMassInfo status={} error=core", e.status);
e
}
}
}
/// `GET …/user` — the FUT user profile: `{"userInfo": …}`, the same userInfo
/// object `userMassInfo` embeds (shared builder). Fail-closed 503 on Core error.
fn handle_user(&self) -> WireResponse {
match self.build_user_mass_info() {
Ok((body, _)) => {
let user_info = body.get("userInfo").cloned().unwrap_or_else(|| json!({}));
eprintln!("utas-host owner=RUST route=user status=200");
json_status(200, &json!({ "userInfo": user_info }))
}
Err(e) => {
eprintln!("utas-host owner=RUST route=user status={} error=core", e.status);
e
}
}
eprintln!(
"utas-host owner=RUST_OBSERVE route=auth status={} ip={:?} outcome={}",
resp.status, client_ip, outcome
);
resp
}
/// `POST /openfut/fifa17/capability` — Rust-owned launcher registration (no
@@ -2530,11 +2673,68 @@ impl Server {
json_status(200, &body)
}
/// `GET …/club/stats/{year,consumables}` — the MY CLUB stat set, computed
/// Core-accurately in Rust (no Python). Player tiers + rare from the Core
/// collection, staff/consumable families from the catalog kind+subtype, nation
/// buckets from the reverse entity resolver. Fail-closed 503 on Core error.
fn handle_club_stats(&self) -> WireResponse {
/// Rust-owned UNCONDITIONAL static acks — `store` eligibility gate, match
/// keepalive, captcha, tfa, livemessage, activeMessage. Byte-identical to the
/// Python oracle's constant responses (no flag gating), so no Python.
fn handle_static_ack(&self, path: &str) -> WireResponse {
let tail = ut_tail(path).unwrap_or("");
let resp = match tail {
"store" => json_status(200, &json!({ "result": "SUCCESS" })),
"match/keepalive" => WireResponse {
status: 204,
headers: Vec::new(),
body: Vec::new(),
},
"captcha" => json_status(
200,
&json!({ "encodedImg": "", "sequence": 0, "sizeBeforeEncode": 0 }),
),
// tfa, livemessage, activeMessage
_ => json_status(200, &json!({})),
};
eprintln!(
"utas-host owner=RUST route=static-ack tail={} status={}",
tail, resp.status
);
resp
}
/// `…/watchList` — transfer watch list. The oracle persists no watches, so
/// add/remove (PUT/POST/DELETE) is a bare `{}` ack; GET returns an empty watch
/// list with the authoritative Core credits. Best-effort credits (0 on Core
/// error) — a cosmetic balance echo, not the authoritative wallet.
fn handle_watchlist(&self, method: &str) -> WireResponse {
if !method.eq_ignore_ascii_case("GET") {
eprintln!("utas-host owner=RUST route=watchlist method={method} status=200");
return json_status(200, &json!({}));
}
let credits = self
.economy
.as_ref()
.and_then(|s| s.econ.balance().ok())
.unwrap_or(0);
eprintln!("utas-host owner=RUST route=watchlist method=GET status=200 credits={credits}");
json_status(200, &json!({ "auctionInfo": [], "credits": credits, "total": 0 }))
}
/// `GET …/club/stats/<mode>` — the MY CLUB stat set, computed Core-accurately
/// in Rust (no Python). Player tiers + rare from the Core collection,
/// staff/consumable families from the catalog kind+subtype, and per-context
/// buckets keyed by the screen's field: nation (year/consumables/club/…),
/// league for `country/<id>`, team for `league/<id>`. Fail-closed 503 on Core.
fn handle_club_stats(&self, path: &str) -> WireResponse {
let mode = ut_tail(path)
.and_then(|t| t.strip_prefix("club/stats/"))
.and_then(|rest| rest.split('/').next())
.unwrap_or("");
// The URL says which SCREEN: `country/<id>` lists LEAGUES (leagueId
// buckets), `league/<id>` lists TEAMS (teamid buckets); all others render
// the default screen (nation buckets). Mirrors fut_club_stats.stats_body.
let ctx = match mode {
"country" => ContextField::League,
"league" => ContextField::Team,
_ => ContextField::Nation,
};
let owned = match self.core.all_owned() {
Ok(o) => o,
Err(e) => {
@@ -2550,6 +2750,8 @@ impl Server {
rating: it.rating as i64,
rare: self.resolver.rareflag_of(it) != 0,
nation_id: self.entities.nation_id(&it.nation).map(|n| n as i64),
league_id: self.entities.league_id(&it.league).map(|n| n as i64),
team_id: self.entities.team_id(&it.club).map(|n| n as i64),
})
.collect();
let players = items
@@ -2557,11 +2759,13 @@ impl Server {
.filter(|i| matches!(i.kind, ContentKind::Player))
.count();
eprintln!(
"utas-host owner=RUST route=club-stats status=200 owned={} players={}",
"utas-host owner=RUST route=club-stats mode={} ctx={:?} status=200 owned={} players={}",
mode,
ctx,
items.len(),
players
);
json_status(200, &club_stats_body(&items))
json_status(200, &club_stats_body(&items, ctx))
}
/// Serve forever on `addr` (thread-per-connection, HTTP/1.1 keep-alive).
@@ -2648,13 +2852,24 @@ fn json_status(status: u16, v: &Value) -> WireResponse {
}
}
/// Observe the minted `sid` from Python's `/ut/auth` JSON response body.
fn observe_sid(body: &[u8]) -> Option<String> {
serde_json::from_slice::<Value>(body)
.ok()?
.get("sid")?
.as_str()
.map(str::to_string)
/// A unique ephemeral temp-file path for the client-data blob store used by
/// [`Server::new`] (tests). Production wires a durable path via
/// [`Server::from_config`]/[`Server::with_clientdata`]. Uniqueness (pid + nanos +
/// a process-local counter) keeps concurrent test servers isolated.
fn ephemeral_clientdata_path() -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static CTR: AtomicU64 = AtomicU64::new(0);
let n = CTR.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!(
"openfut-utas-clientdata-{}-{}-{}.json",
std::process::id(),
nanos,
n
))
}
/// A validated capability registration request.
@@ -3098,15 +3313,15 @@ mod tests {
assert_eq!(classify("get", "/ut/game/fifa18/club"), Route::Club);
// method must be GET
assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::Passthrough);
// near-misses stay on Python: bare club/stats and the country sub-screen are
// NOT the exact /club route and are not (yet) migrated arms.
// bare `club/stats` (no trailing slash) is not a migrated arm -> Python.
assert_eq!(
classify("GET", "/ut/game/fifa17/club/stats"),
Route::Passthrough
);
// club/stats/<mode> screens are Rust-owned (nation/league/team contexts).
assert_eq!(
classify("GET", "/ut/game/fifa17/club/stats/country/54"),
Route::Passthrough
Route::ClubStats
);
assert_eq!(
classify("GET", "/ut/game/fifa17/clubUser"),
@@ -3286,19 +3501,17 @@ mod tests {
);
assert_eq!(
classify("POST", "/openfut/account/sync"),
Route::Passthrough
Route::AccountSync
);
}
#[test]
fn observe_sid_extracts_minted_sid() {
let body = br#"{"protocol":1,"sid":"OPENFUT-SID-42C15A6F78DC6E74","serverTime":"x"}"#;
assert_eq!(classify("POST", "/ut/delete/auth"), Route::Auth);
assert_eq!(
observe_sid(body).as_deref(),
Some("OPENFUT-SID-42C15A6F78DC6E74")
classify("PUT", "/ut/game/fifa17/clientdata/userHubData"),
Route::ClientData
);
assert_eq!(
classify("GET", "/ut/game/fifa17/clientdata/userHubData"),
Route::ClientData
);
assert_eq!(observe_sid(b"{}"), None);
assert_eq!(observe_sid(b"not json"), None);
}
#[test]
@@ -3611,15 +3824,23 @@ mod tests {
"/ut/game/fifa17/club/stats/consumables",
Route::ClubStats,
),
(
"PUT",
"/ut/game/fifa17/clientdata/userHubData",
Route::ClientData,
),
(
"GET",
"/ut/game/fifa17/clientdata/userHubData",
Route::ClientData,
),
("POST", "/openfut/account/sync", Route::AccountSync),
];
for (m, p, want) in owned {
assert_eq!(classify(m, p), *want, "OWN: {m} {p}");
}
// Still Python (not yet migrated) / lookalikes / wrong method.
let proxied: &[(&str, &str)] = &[
("GET", "/ut/game/fifa17/club/stats/country/54"),
("PUT", "/ut/game/fifa17/clientdata/userHubData"),
("POST", "/openfut/account/sync"),
("GET", "/ut/game/fifa17/settingsfoo"),
("POST", "/ut/game/fifa17/match/reset"), // match/reset is PUT-only
("GET", "/ut/game/fifa17/match/reset"),
@@ -759,6 +759,7 @@ fn from_config(base: &str, dir: &std::path::Path) -> openfut_utas_host::config::
persona_id: 33_068_179,
market_db_path: dir.join("market.db").to_string_lossy().into_owned(),
pile_db_path: dir.join("pile.db").to_string_lossy().into_owned(),
clientdata_path: dir.join("clientdata.json").to_string_lossy().into_owned(),
}
}
+6 -1
View File
@@ -795,7 +795,8 @@ fn classify_squad_and_usermassinfo_routes() {
Route::UserMassInfo
);
// GET /squad/active is now Core-backed (SquadActive). A squad PUT is never a
// GET; a numeric GET /squad/<n> for a non-active squad stays on Python.
// GET. GET /squad/0 IS the active squad (id 0) -> SquadActive; a numeric
// GET /squad/<n> for a NON-active squad (n != 0) stays on Python.
assert_eq!(
classify("GET", "/ut/game/fifa17/squad/active"),
Route::SquadActive
@@ -806,6 +807,10 @@ fn classify_squad_and_usermassinfo_routes() {
);
assert_eq!(
classify("GET", "/ut/game/fifa17/squad/0"),
Route::SquadActive
);
assert_eq!(
classify("GET", "/ut/game/fifa17/squad/5"),
Route::Passthrough
);
assert_eq!(