From e06fd5721150c3c30e6db44b519b3bce21ab45a0 Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 17 Aug 2026 16:04:20 +0000 Subject: [PATCH] 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/: 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. --- docs/MATCH_LIFECYCLE.md | 208 ++++++ docs/PRODUCTION_AUTHORITY_MATRIX.md | 54 +- docs/PYTHON_RETIREMENT_PLAN.md | 9 +- .../route-shapes-2026-08-17/_index.json | 67 ++ .../route-shapes-2026-08-17/champion.json | 1 + .../route-shapes-2026-08-17/clubUser.json | 1 + .../route-shapes-2026-08-17/defid.json | 53 ++ .../defs_resource.json | 53 ++ .../route-shapes-2026-08-17/draft_state.json | 9 + .../route-shapes-2026-08-17/marketdata.json | 12 + .../route-shapes-2026-08-17/sbs_sets.json | 35 + .../route-shapes-2026-08-17/season.json | 1 + .../route-shapes-2026-08-17/squad_0.json | 707 ++++++++++++++++++ .../route-shapes-2026-08-17/tournament.json | 1 + .../route-shapes-2026-08-17/user.json | 54 ++ .../route-shapes-2026-08-17/user_list.json | 1 + .../route-shapes-2026-08-17/watchList.json | 5 + openfut-adapter-fifa17/src/fut/club_stats.rs | 121 ++- openfut-adapter-fifa17/src/fut/non_economy.rs | 269 +++++++ openfut-launcher | 2 +- openfut-utas-host/src/clientdata_store.rs | 102 +++ openfut-utas-host/src/config.rs | 24 +- openfut-utas-host/src/lib.rs | 411 +++++++--- .../tests/economy_integration.rs | 1 + openfut-utas-host/tests/host_test.rs | 7 +- 25 files changed, 2061 insertions(+), 147 deletions(-) create mode 100644 docs/MATCH_LIFECYCLE.md create mode 100644 docs/evidence/route-shapes-2026-08-17/_index.json create mode 100644 docs/evidence/route-shapes-2026-08-17/champion.json create mode 100644 docs/evidence/route-shapes-2026-08-17/clubUser.json create mode 100644 docs/evidence/route-shapes-2026-08-17/defid.json create mode 100644 docs/evidence/route-shapes-2026-08-17/defs_resource.json create mode 100644 docs/evidence/route-shapes-2026-08-17/draft_state.json create mode 100644 docs/evidence/route-shapes-2026-08-17/marketdata.json create mode 100644 docs/evidence/route-shapes-2026-08-17/sbs_sets.json create mode 100644 docs/evidence/route-shapes-2026-08-17/season.json create mode 100644 docs/evidence/route-shapes-2026-08-17/squad_0.json create mode 100644 docs/evidence/route-shapes-2026-08-17/tournament.json create mode 100644 docs/evidence/route-shapes-2026-08-17/user.json create mode 100644 docs/evidence/route-shapes-2026-08-17/user_list.json create mode 100644 docs/evidence/route-shapes-2026-08-17/watchList.json create mode 100644 openfut-utas-host/src/clientdata_store.rs diff --git a/docs/MATCH_LIFECYCLE.md b/docs/MATCH_LIFECYCLE.md new file mode 100644 index 0000000..dfff4bd --- /dev/null +++ b/docs/MATCH_LIFECYCLE.md @@ -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//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//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//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": , "reportIdEnabled": false, "id": } +``` +Effect: allocates a match id; **advances `nextItemId` by 1** (PROVEN, +`test_match_lifecycle.py:51`). + +### READY — `POST|PUT /ut//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": , "opponentPersonaId": } +``` +- `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//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//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//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//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": , + "matchCoins": , + "seasonCoins": 0, + "tournamentCoins": 0, + "boostConis": 0, // EA's typo — exact key required + "participationAward": , + "teamOfTournamentWinner": false, + "gameModeAward": { "coins": } +} +``` +> **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//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//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`. diff --git a/docs/PRODUCTION_AUTHORITY_MATRIX.md b/docs/PRODUCTION_AUTHORITY_MATRIX.md index 0e1a2c1..e6150f7 100644 --- a/docs/PRODUCTION_AUTHORITY_MATRIX.md +++ b/docs/PRODUCTION_AUTHORITY_MATRIX.md @@ -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/ (POST/PUT/GET) | R | coins,inv,listings | handle_market_buy | NO | | DELETE /ut/delete/../trade/ | 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/ | 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/ | 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≠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 diff --git a/docs/PYTHON_RETIREMENT_PLAN.md b/docs/PYTHON_RETIREMENT_PLAN.md index 49926b4..c2dc4a3 100644 --- a/docs/PYTHON_RETIREMENT_PLAN.md +++ b/docs/PYTHON_RETIREMENT_PLAN.md @@ -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/, 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/, 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. diff --git a/docs/evidence/route-shapes-2026-08-17/_index.json b/docs/evidence/route-shapes-2026-08-17/_index.json new file mode 100644 index 0000000..f7c4cee --- /dev/null +++ b/docs/evidence/route-shapes-2026-08-17/_index.json @@ -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 + } +} \ No newline at end of file diff --git a/docs/evidence/route-shapes-2026-08-17/champion.json b/docs/evidence/route-shapes-2026-08-17/champion.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/docs/evidence/route-shapes-2026-08-17/champion.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/docs/evidence/route-shapes-2026-08-17/clubUser.json b/docs/evidence/route-shapes-2026-08-17/clubUser.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/docs/evidence/route-shapes-2026-08-17/clubUser.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/docs/evidence/route-shapes-2026-08-17/defid.json b/docs/evidence/route-shapes-2026-08-17/defid.json new file mode 100644 index 0000000..6ff848c --- /dev/null +++ b/docs/evidence/route-shapes-2026-08-17/defid.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/docs/evidence/route-shapes-2026-08-17/defs_resource.json b/docs/evidence/route-shapes-2026-08-17/defs_resource.json new file mode 100644 index 0000000..6ff848c --- /dev/null +++ b/docs/evidence/route-shapes-2026-08-17/defs_resource.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/docs/evidence/route-shapes-2026-08-17/draft_state.json b/docs/evidence/route-shapes-2026-08-17/draft_state.json new file mode 100644 index 0000000..d13ed78 --- /dev/null +++ b/docs/evidence/route-shapes-2026-08-17/draft_state.json @@ -0,0 +1,9 @@ +[ + { + "gamesWonCurrentMatch": 0, + "roundsInfo": [], + "squadState": "INVALID", + "stateParam1": "INVALID", + "stateParam2": "0" + } +] \ No newline at end of file diff --git a/docs/evidence/route-shapes-2026-08-17/marketdata.json b/docs/evidence/route-shapes-2026-08-17/marketdata.json new file mode 100644 index 0000000..4fc30f4 --- /dev/null +++ b/docs/evidence/route-shapes-2026-08-17/marketdata.json @@ -0,0 +1,12 @@ +[ + { + "defId": 200389, + "maxPrice": 15000, + "minPrice": 150 + }, + { + "defId": 200104, + "maxPrice": 15000, + "minPrice": 150 + } +] \ No newline at end of file diff --git a/docs/evidence/route-shapes-2026-08-17/sbs_sets.json b/docs/evidence/route-shapes-2026-08-17/sbs_sets.json new file mode 100644 index 0000000..f68b2c2 --- /dev/null +++ b/docs/evidence/route-shapes-2026-08-17/sbs_sets.json @@ -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 + } + ] + } + ] +} \ No newline at end of file diff --git a/docs/evidence/route-shapes-2026-08-17/season.json b/docs/evidence/route-shapes-2026-08-17/season.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/docs/evidence/route-shapes-2026-08-17/season.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/docs/evidence/route-shapes-2026-08-17/squad_0.json b/docs/evidence/route-shapes-2026-08-17/squad_0.json new file mode 100644 index 0000000..b7c0a69 --- /dev/null +++ b/docs/evidence/route-shapes-2026-08-17/squad_0.json @@ -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 +} \ No newline at end of file diff --git a/docs/evidence/route-shapes-2026-08-17/tournament.json b/docs/evidence/route-shapes-2026-08-17/tournament.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/docs/evidence/route-shapes-2026-08-17/tournament.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/docs/evidence/route-shapes-2026-08-17/user.json b/docs/evidence/route-shapes-2026-08-17/user.json new file mode 100644 index 0000000..73e9d3d --- /dev/null +++ b/docs/evidence/route-shapes-2026-08-17/user.json @@ -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 + } +} \ No newline at end of file diff --git a/docs/evidence/route-shapes-2026-08-17/user_list.json b/docs/evidence/route-shapes-2026-08-17/user_list.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/docs/evidence/route-shapes-2026-08-17/user_list.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/docs/evidence/route-shapes-2026-08-17/watchList.json b/docs/evidence/route-shapes-2026-08-17/watchList.json new file mode 100644 index 0000000..4676e2d --- /dev/null +++ b/docs/evidence/route-shapes-2026-08-17/watchList.json @@ -0,0 +1,5 @@ +{ + "auctionInfo": [], + "credits": 29876776, + "total": 0 +} \ No newline at end of file diff --git a/openfut-adapter-fifa17/src/fut/club_stats.rs b/openfut-adapter-fifa17/src/fut/club_stats.rs index 7e52dfe..70f6169 100644 --- a/openfut-adapter-fifa17/src/fut/club_stats.rs +++ b/openfut-adapter-fifa17/src/fut/club_stats.rs @@ -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, + pub league_id: Option, + pub team_id: Option, +} + +/// 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/`: league (leagueId) buckets, tier stats. +/// * `Team` — URL `club/stats/league/`: 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 = 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 = g.iter().map(|(sid, v)| row(1, 0, *sid, *v)).collect(); - // ---- per-nation buckets (contextId 3, contextValue = nation id) ---- - let mut by_nation: BTreeMap> = 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> = 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")); + } } diff --git a/openfut-adapter-fifa17/src/fut/non_economy.rs b/openfut-adapter-fifa17/src/fut/non_economy.rs index 2265bcd..337c545 100644 --- a/openfut-adapter-fifa17/src/fut/non_economy.rs +++ b/openfut-adapter-fifa17/src/fut/non_economy.rs @@ -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 = 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 { + 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::().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"); + } } diff --git a/openfut-launcher b/openfut-launcher index d1a71bd..c277213 160000 --- a/openfut-launcher +++ b/openfut-launcher @@ -1 +1 @@ -Subproject commit d1a71bd5a1466b6d04efc7289bace30ec6da6bf2 +Subproject commit c2772132c106d98f79f6e34d8bdd7d40319cc90a diff --git a/openfut-utas-host/src/clientdata_store.rs b/openfut-utas-host/src/clientdata_store.rs new file mode 100644 index 0000000..7625a0a --- /dev/null +++ b/openfut-utas-host/src/clientdata_store.rs @@ -0,0 +1,102 @@ +//! Durable FIFA 17 **client-data blob** store (`clientdata` / `userHubData`). +//! +//! FIFA persists opaque per-user client blobs via `PUT/POST …/clientdata/` +//! and reads them back via `GET …/clientdata/`. 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 +//! `:` 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>, +} + +impl ClientDataStore { + /// Open the store, loading any previously-persisted blobs. A missing or + /// unreadable file starts empty. + pub fn open(path: impl Into) -> Self { + let path = path.into(); + let map = std::fs::read(&path) + .ok() + .and_then(|b| serde_json::from_slice::>(&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 `:`, or `None` if never written. + pub fn get(&self, persona: i64, key: &str) -> Option { + self.map.lock().get(&Self::compound_key(persona, key)).cloned() + } + + /// Store `value` under `:` 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); + } +} diff --git a/openfut-utas-host/src/config.rs b/openfut-utas-host/src/config.rs index 59b1938..2699418 100644 --- a/openfut-utas-host/src/config.rs +++ b/openfut-utas-host/src/config.rs @@ -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 { impl HostConfig { pub fn from_env() -> Result { + 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() +} diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 62f512a..3cd1fba 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -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/` — 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>, + /// 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, } 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) -> 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::(&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::() + ); + 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/` — opaque per-user client blob store. GET + /// returns the stored blob for `:` (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/` — 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/`, team for `league/`. 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/` lists LEAGUES (leagueId + // buckets), `league/` 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 { - serde_json::from_slice::(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/ 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"), diff --git a/openfut-utas-host/tests/economy_integration.rs b/openfut-utas-host/tests/economy_integration.rs index 8c0bee1..2010d2b 100644 --- a/openfut-utas-host/tests/economy_integration.rs +++ b/openfut-utas-host/tests/economy_integration.rs @@ -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(), } } diff --git a/openfut-utas-host/tests/host_test.rs b/openfut-utas-host/tests/host_test.rs index 33ca265..f26a742 100644 --- a/openfut-utas-host/tests/host_test.rs +++ b/openfut-utas-host/tests/host_test.rs @@ -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/ for a non-active squad stays on Python. + // GET. GET /squad/0 IS the active squad (id 0) -> SquadActive; a numeric + // GET /squad/ 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!(