Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1d7ed2570 | |||
| dcbef721f2 | |||
| 772f8a615a | |||
| bf9ae20367 | |||
| 58d1f9426f | |||
| 3cd31c4322 | |||
| ae5feb05b7 | |||
| f2c4927ea6 | |||
| aa2abc2772 | |||
| 1aa84afa9a | |||
| 33e9118329 | |||
| e06fd57211 | |||
| 42fd3c7e90 | |||
| 0bc71dbd74 | |||
| 2ecd830d75 | |||
| 3a51b0ebd4 | |||
| ad406f21bd | |||
| 12fb9fc38b | |||
| 7b580a0070 | |||
| 22443a3810 | |||
| 0fce1e521c | |||
| 71fcf5e251 | |||
| 979e71fbea | |||
| 45e0b0bd95 | |||
| 70eb3fc13f | |||
| b30aa352f6 | |||
| 67cc33cfee | |||
| 6eec3b9ec7 | |||
| 57773b98ec | |||
| fe9b899a0e | |||
| abe9e663c1 | |||
| f5a33eb58c | |||
| a85090c3c6 | |||
| 97d48d8371 |
Generated
-7
@@ -3185,13 +3185,6 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-hook"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-host-config"
|
||||
version = "0.1.0"
|
||||
|
||||
+8
-1
@@ -15,7 +15,14 @@ members = [
|
||||
"openfut-import-fifa17",
|
||||
"openfut-bridge",
|
||||
"openfut-launcher",
|
||||
"openfut-launcher/openfut-hook",
|
||||
"fifa-blaze/crates/blaze-proto",
|
||||
"fifa-blaze/crates/server",
|
||||
]
|
||||
# openfut-hook is a Windows-only version.dll proxy injected into the FIFA client.
|
||||
# It MUST build with its own [profile.release] (panic="abort" — unwinding across
|
||||
# the DllMain/FFI boundary into the game process is UB — plus strip + opt-level="s").
|
||||
# Cargo ignores a non-root member's profile and forbids per-package `panic` overrides,
|
||||
# so the hook is deliberately EXCLUDED from this workspace to build as its own root
|
||||
# (this also lands its artifact in openfut-hook/target/, matching the launcher's
|
||||
# config.rs default hook_dll_path). Build: cargo build --release --target x86_64-pc-windows-gnu.
|
||||
exclude = ["openfut-launcher/openfut-hook"]
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# openfut-core — Correctness Issues (audit 2026-08-17)
|
||||
|
||||
Read-only audit of `openfut-core` (game-agnostic axum + SQLite/sqlx economy authority).
|
||||
Four reported issue classes confirmed with exact `file:line` evidence, **plus a bonus
|
||||
HIGH-severity SBC duplicate-card economy exploit**. Nothing here is fixed yet —
|
||||
fixing bumps Core off the frozen P1 reference (`fbb54ea`, the current known-good
|
||||
production Core) and one item needs a DB migration + prod backfill, so this needs a
|
||||
**go/no-go** before rebuild+redeploy.
|
||||
|
||||
> **Zero live users right now**, so the HIGH-severity economy exploits are not
|
||||
> currently exploitable — but they are the exact class (coin overspend + card
|
||||
> duplication) that crashed live clients earlier (project memory), so they should be
|
||||
> fixed before any real play.
|
||||
|
||||
## Architecture context (why the bugs cluster)
|
||||
|
||||
Two generations of economy code coexist:
|
||||
- **NEW** `services/economy.rs` + `routes/economy.rs` — **fully atomic + validated**:
|
||||
every compound op acquires a connection, `BEGIN IMMEDIATE`, composes
|
||||
transaction-scoped primitives (`debit`/`credit`/`add_item`/`remove_item`/
|
||||
`consume_entitlement`), commits/rolls back via `finish()`, rejects negative amounts,
|
||||
and has an extensive in-module test suite. **This is the reference fix pattern.**
|
||||
- **OLD** per-feature services (`club`, `pack`, `market`, `sbc`, `checkin`, `upgrades`,
|
||||
`match_service`, `season`) — predate it, still do read/check/write directly against
|
||||
the `&Pool` with each statement on its own connection: no transaction. `economy.rs`'s
|
||||
own module doc explicitly warns these "cannot offer that guarantee".
|
||||
|
||||
Issues 2 and 3 live entirely in the OLD generation; the fix is to route them through
|
||||
`economy.rs`'s proven atomic ops (or give the pool helpers `&mut SqliteConnection`
|
||||
transactional variants). Request flow is `X-OpenFUT-Game` header → active profile →
|
||||
club → service; clients never pass a `club_id`, so cross-club access is **not** a
|
||||
vector — the risks are intra-club concurrency + unvalidated payloads.
|
||||
|
||||
---
|
||||
|
||||
## Issue 1 — `sbc_submissions` missing `club_id` (milestone always 0) · **LOW**
|
||||
|
||||
- **Root cause:** `migrations/0001_initial.sql:96-102` creates `sbc_submissions(id,
|
||||
profile_id, sbc_id, submitted_card_ids, passed, submitted_at)` — no `club_id`, and no
|
||||
later migration adds one. But `src/routes/club.rs:94-99` (`get_milestones`) runs
|
||||
`SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1`. SQLite errors
|
||||
`no such column: club_id`; the error is swallowed by `.unwrap_or(0)` → the
|
||||
`sbcs_completed` milestone is **always 0**. Writer `services/sbc.rs:74-83` inserts
|
||||
`profile_id`, not `club_id`.
|
||||
- **Impact:** wrong milestone stat only. No crash, no economy corruption.
|
||||
- **Fix (matches the ticket — needs migration + backfill):** new migration `0019`:
|
||||
`ALTER TABLE sbc_submissions ADD COLUMN club_id TEXT;` then backfill
|
||||
`UPDATE sbc_submissions SET club_id = (SELECT c.id FROM clubs c WHERE c.profile_id =
|
||||
sbc_submissions.profile_id);` and bind `club_id` in `sbc.rs:submit_sbc`'s INSERT
|
||||
(`club_id` is already a param at `sbc.rs:41`).
|
||||
- **Simpler alternative (no migration):** change the `club.rs:95` query to
|
||||
`WHERE profile_id = ?` (column already exists). Ticket asks for the column, so both
|
||||
are recorded.
|
||||
- **Migration required:** YES (for the ticket's fix); NO (for the alternative).
|
||||
|
||||
## Issue 2 — Non-atomic check-then-act economy mutations (TOCTOU) · **HIGH**
|
||||
|
||||
Each does read → check → write across multiple pool round-trips with no
|
||||
`BEGIN IMMEDIATE`, so concurrent requests race → overspend / duplication / double reward:
|
||||
|
||||
| Site | Race |
|
||||
|---|---|
|
||||
| `services/club.rs:88-115` `spend_coins` | SELECT coins → `if balance<amount` → UPDATE; two concurrent spends both pass → **overspend / negative balance**. Shared primitive used by all callers below. |
|
||||
| `services/pack.rs:58-141` `open_pack` | read `pack.opened` → INSERT cards loop → UPDATE opened=1; concurrent double-open → **card duplication** |
|
||||
| `services/pack.rs:144-160` `buy_pack` | `spend_coins` then `grant_pack`, separate ops → crash between = coins gone, no pack |
|
||||
| `services/market.rs:129-186` `buy_listing` | SELECT sold=0 → spend → UPDATE sold=1 → INSERT; concurrent double-buy → **two cards minted** |
|
||||
| `services/market.rs:188+` `sell_card` | SELECT owned → DELETE → add_coins; concurrent double-sell → **double credit** |
|
||||
| `services/sbc.rs:35-107` `submit_sbc` | validate → DELETE cards → INSERT submission → add_coins/grant_pack; same cards to two SBCs → **double reward** |
|
||||
| `services/checkin.rs:60-120` `claim` | SELECT last → same-day check → reward → INSERT; concurrent → **double claim** |
|
||||
| `services/upgrades.rs:64-95` `change_position` | fetch → spend_coins → UPDATE |
|
||||
| `services/match_service.rs:97-235` + `season.rs` reward | many sequential writes; partial failure leaves partial rewards |
|
||||
|
||||
- **Impact:** corrupts economy state (coin overspend + card duplication).
|
||||
- **Fix:** wrap each compound op in one `BEGIN IMMEDIATE`…`finish()` transaction
|
||||
exactly as `services/economy.rs:214-236` already does; route pack/market/sbc/checkin/
|
||||
upgrades through `economy.rs`'s composed atomic ops (or add `&mut SqliteConnection`
|
||||
variants of `spend_coins`/`add_coins`/`grant_pack`/`add_item`).
|
||||
- **Migration required:** NO (code-only).
|
||||
|
||||
## Issue 3 — Missing input validation: SBC duplicate-card exploit · **HIGH**
|
||||
|
||||
- **Root cause:** `services/sbc.rs:53-70` iterates `req.owned_card_ids` with **no dedup
|
||||
and no length bound**. The same `owned_card_id` repeated N times resolves the same card
|
||||
N times (each `fetch_optional` succeeds); `validate_sbc` (`sbc.rs:110-116`) counts it
|
||||
toward `squad_size` and passes; the DELETE loop deletes it once → **a user satisfies
|
||||
any SBC with ONE card duplicated → free rewards**. Entry point `routes/sbc.rs:30-49`
|
||||
forwards `req` unvalidated. Unbounded Vec length is also a DoS.
|
||||
- **Fix:** in `submit_sbc`, reject duplicate ids (collect into a `HashSet`, compare
|
||||
`len`) and bound the list (e.g. ≤ 30) before resolving → `AppError::BadRequest`.
|
||||
- **Already-good validation (no change):** `economy.rs:64-67,84-87` reject negative
|
||||
amounts; `match_service.rs:100-104` clamps goals 0..99; `upgrades.rs` validates
|
||||
boost 1..3 / positions. Minor: `routes/economy.rs post_grant_reward` forwards an
|
||||
unbounded `amount` (trusted host caller; add an upper-bound sanity guard).
|
||||
- **Migration required:** NO.
|
||||
|
||||
## Issue 4 — `season.rs` panics + checkin index panic · **LOW**
|
||||
|
||||
- **Root cause:** `services/season.rs` `.expect()` on `fetch_optional` Options at
|
||||
`:23` (`get_or_create`), `:69` and `:144` (`record_match`) — panic if the `seasons`
|
||||
row is absent when expected. `seasons.profile_id` is PRIMARY KEY
|
||||
(`migrations/0006_seasons_loans_packs.sql:2`), so the concurrent-insert case surfaces
|
||||
as a UNIQUE error via `?` (not the panic), lowering probability — but it still
|
||||
panics-on-invariant, aborting that request (axum → 500 for the request; not a full
|
||||
server crash).
|
||||
- **Secondary:** `services/checkin.rs:~52,~88` index `STREAK_COINS[idx]` with
|
||||
`idx = ((streak-1)%7) as usize`; Rust `%` can be negative → a corrupt/negative
|
||||
persisted `streak_day` yields a negative index → **panic**.
|
||||
- **Fix:** replace each `.expect(...)` with
|
||||
`.ok_or_else(|| AppError::Internal("season row missing".into()))?`; guard the checkin
|
||||
index with `.rem_euclid(7)` (or clamp `streak_day >= 1` on read).
|
||||
- **Migration required:** NO.
|
||||
|
||||
---
|
||||
|
||||
## Fix plan summary
|
||||
|
||||
| Issue | Severity | Migration | Files |
|
||||
|---|---|---|---|
|
||||
| 1 sbc_submissions club_id | LOW | YES (or none via alt) | `migrations/0001` (+new 0019), `routes/club.rs`, `services/sbc.rs` |
|
||||
| 2 non-atomic mutations | HIGH | NO | `services/{club,pack,market,sbc,checkin,upgrades,match_service,season}.rs` → route through `services/economy.rs` |
|
||||
| 3 SBC duplicate-card exploit | HIGH | NO | `services/sbc.rs`, `routes/sbc.rs` |
|
||||
| 4 season/checkin panics | LOW | NO | `services/season.rs`, `services/checkin.rs` |
|
||||
|
||||
**Recommended order:** 3 (smallest, highest-value: kills the free-reward exploit) → 2
|
||||
(the transactional refactor, largest) → 4 (defensive hygiene) → 1 (cosmetic; do with
|
||||
the alt query unless the column is wanted).
|
||||
|
||||
**Deployment note:** all of these change `openfut-core`, which is currently frozen at
|
||||
the P1 reference (`fbb54ea`) in production. Fixing + rebuilding + redeploying prod-core
|
||||
is a deliberate step off that reference — get a go/no-go first. Only Issue 1's
|
||||
column-add needs a migration + prod backfill; 2/3/4 are code-only.
|
||||
|
||||
---
|
||||
|
||||
## Resolution (2026-08-17, on `openfut-core` @ `fbb54ea` + these edits)
|
||||
|
||||
All four issue classes fixed in the canonical superproject submodule
|
||||
`openfut-core`; full test suite green (179 tests) + clippy clean + a new
|
||||
regression test `tests/integration_test.rs::test_sbc_rejects_duplicate_cards`.
|
||||
|
||||
| Issue | Fix | Files |
|
||||
|---|---|---|
|
||||
| 3 SBC dup-card exploit | dedup (`HashSet`) + `MAX_SBC_CARDS`=30 bound in `submit_sbc`, before card resolution → `BadRequest` | `services/sbc.rs` |
|
||||
| 1 sbc_submissions club_id | migration `0019` adds `club_id` + backfills from `clubs`; `submit_sbc` INSERT now binds `club_id` | `migrations/0019_*.sql`, `services/sbc.rs` |
|
||||
| 4 season/checkin panics | `.expect()` → `.ok_or_else(AppError::Internal)?` (3 sites); checkin index `% 7` → `.rem_euclid(7)` (2 sites) | `services/season.rs`, `services/checkin.rs` |
|
||||
| 2 non-atomic mutations | statement-level compare-and-swap (see below) | `services/{club,pack,market,checkin}.rs` |
|
||||
|
||||
### Issue 2 — how it was fixed, and the residual
|
||||
|
||||
Rather than the full transaction refactor (threading `&mut SqliteConnection`
|
||||
through every service), the concurrency-exploitable races were closed with
|
||||
single-statement **compare-and-swap** — the atomic unit SQLite already gives us,
|
||||
no transaction plumbing, minimal blast radius on the working prod economy path:
|
||||
|
||||
- `club::spend_coins` — `UPDATE … SET coins = coins - ? WHERE id = ? AND coins >= ?`
|
||||
+ `rows_affected` guard; also rejects negative amounts. Kills **overspend** for
|
||||
every caller (the shared root primitive).
|
||||
- `pack::open_pack` — claims the pack (`UPDATE … opened = 1 WHERE … AND opened = 0`)
|
||||
**before** minting cards; loser aborts. Kills **card duplication** via double-open.
|
||||
- `market::buy_listing` — claims the listing (`sold 0→1`) before charging; releases
|
||||
the claim if the debit fails. Kills **double-mint**.
|
||||
- `market::sell_card` — `DELETE … WHERE id = ? AND club_id = ?` + `rows_affected`
|
||||
guard before crediting. Kills **double-credit** via double-sell.
|
||||
- `checkin::claim` — conditional `INSERT … SELECT … WHERE NOT EXISTS (today's row)`
|
||||
+ `rows_affected` guard; pays out only if the claim landed. Kills **double-claim**.
|
||||
|
||||
**Residual (accepted, documented):** the *multi-statement all-or-nothing* edges that
|
||||
need a real transaction to close — `pack::buy_pack` (spend then grant: a crash between
|
||||
loses coins with no pack), `sbc::submit_sbc` (concurrent submits sharing cards could
|
||||
double-consume mid-loop), and `match_service`/`season` reward chains (partial writes on
|
||||
crash). These are **partial-failure durability edges, not statement-level races**, and
|
||||
require concurrency that a single-player FIFA17 client does not generate. Closing them
|
||||
is the `&mut SqliteConnection` transaction refactor originally proposed; deferred as
|
||||
low-value for single-player. Overspend + duplication + double-credit — the vectors that
|
||||
corrupt economy state — are all closed.
|
||||
@@ -0,0 +1,79 @@
|
||||
# FIFA17 Content Completeness (33-record gap)
|
||||
|
||||
Evidence: ContentGapMap scout vs import-input.json (persona 33068179, 1995 items),
|
||||
fifa17-recon/data/tables, manifest/fifa17-import-manifest.json (OBSERVED).
|
||||
|
||||
## Summary
|
||||
| Category | Expected | Resolvable | Unrecoverable |
|
||||
|---|---|---|---|
|
||||
| Legend players | 13 instances (10 defs) | 0 | 13 (need .105 Legends locale names) |
|
||||
| Consumables | 17 | 17 | 0 |
|
||||
| Staff | 3 | 3 | 0 |
|
||||
|
||||
## Consumables (17/17 RESOLVABLE — carddbid present in fcc tables; semantics from cardsubtypeid)
|
||||
| wire_id | resourceId | subtype | kind | table |
|
||||
|---|---|---|---|---|
|
||||
| 100000239 | 5003012 | 54 | gk_training | fcc_trainingcards |
|
||||
| 100000249 | 5003011 | 54 | gk_training | fcc_trainingcards |
|
||||
| 100000260 | 5003004 | 52 | gk_training | fcc_trainingcards |
|
||||
| 100000272 | 5003059 | 91 | position/playstyle mod | fcc_trainingcards |
|
||||
| 100000250 | 5003060 | 92 | position/playstyle mod | fcc_trainingcards |
|
||||
| 100000327 | 5003065 | 97 | position/playstyle mod | fcc_trainingcards |
|
||||
| 100000238 | 5003066 | 98 | position/playstyle mod | fcc_trainingcards |
|
||||
| 100000261 | 5003068 | 100 | position/playstyle mod | fcc_trainingcards |
|
||||
| 100000316 | 5003068 | 100 | position/playstyle mod | fcc_trainingcards |
|
||||
| 100000293 | 5003103 | 258 | player_playstyle | fcc_trainingcards |
|
||||
| 100000326 | 5003112 | 267 | player_playstyle | fcc_trainingcards |
|
||||
| 100000283 | 5003116 | 271 | gk_playstyle | fcc_trainingcards |
|
||||
| 100000294 | 5001004 | 201 | player_contract | fcc_contractcards |
|
||||
| 100000304 | 5001008 | 202 | manager_contract | fcc_contractcards |
|
||||
| 100000305 | 5001009 | 202 | manager_contract | fcc_contractcards |
|
||||
| 100000315 | 5002027 | 217 | healing | fcc_healingcards |
|
||||
| 100000426 | 5002013 | 213 | healing | fcc_healingcards |
|
||||
|
||||
## Staff (3/3 RESOLVABLE)
|
||||
| wire_id | resourceId | subtype | role | table |
|
||||
|---|---|---|---|---|
|
||||
| 100000271 | 3000083 | 8 | fitnesscoach | fitnesscoachcards |
|
||||
| 100000427 | 3000083 | 8 | fitnesscoach | fitnesscoachcards |
|
||||
| 100000282 | 9000081 | 6 | gkcoach | gkcoachcards |
|
||||
|
||||
## Legends (13 instances / 10 defs — UNRECOVERABLE from .120)
|
||||
All defer via NoName gate (openfut-import-fifa17/src/lib.rs:476-478). 6 assets absent
|
||||
from players.json; 236250/236253/236257 resolve only to placeholder nameid 24313='171918'.
|
||||
dcplayernames.json/editedplayernames.json EMPTY in .120 dump. Version formula holds for all
|
||||
(resourceId == (version<<24)|assetId) — so they auto-promote the instant a .105-derived
|
||||
name row exists; NO code change needed, only name data.
|
||||
|
||||
| asset | resourceId(ver) | rareflag/rating/pos | wire copies |
|
||||
|---|---|---|---|
|
||||
| 169193 | 169193(v0) | 1/r87/CDM | 100000057,100000083,100000146,100000157 |
|
||||
| 211029 | 211029(v0) | 1/r73/CB | 100000165 |
|
||||
| 224512 | 224512(v0) | 1/r67/LB | 100000170 |
|
||||
| 227403 | 227403(v0) | 1/r64/ST | 100000160 |
|
||||
| 236743 | 236743(v0) | 1/r60/LB | 100000163 |
|
||||
| 237510 | 237510(v0) | 1/r59/CB | 100000162 |
|
||||
| 236250 | 17013466(v1) | 3/r78/ST | 100001102 |
|
||||
| 236253 | 17013469(v1) | 3/r77/CAM | 100001932 |
|
||||
| 236253 | 100899549(v6) | 21/r77/CAM | 100001472 |
|
||||
| 236257 | 117676769(v7) | 22/r78/LM | 100001043 |
|
||||
|
||||
### .105 read-only verdict (PROVEN 2026-08-14) — UNRECOVERABLE, confirmed
|
||||
SSH read-only to the FIFA machine (10.10.0.105). Install `/mnt/games/FIFA 17`
|
||||
is retail PC (FIFA17.exe, build 2017-06-09, changelist 3175939, sku FFA17PCC).
|
||||
The resident FIFA17.exe database (recon `data/tables`) shows the DLC/Legend name
|
||||
tables ship ZERO rows in the retail PC build:
|
||||
- `dcplayernames.json`: rowcount:0, rows_emitted:0 (nameid range 30000-35000 = DLC names)
|
||||
- `editedplayernames.json`: rowcount:0, rows_emitted:0
|
||||
- `playernames.json`: 24314 rows (standard players only)
|
||||
FUT Legends were Xbox-One-exclusive in FIFA 17; the PC client contains no Legend
|
||||
names at all. The 13 Legend instances are therefore GENUINELY UNRECOVERABLE from
|
||||
any PC install (.105 == .120, same retail build) — NOT a filtering artifact and
|
||||
NOT fabricated. Architecture is already correct: they auto-promote the instant a
|
||||
name row is supplied (e.g. from an Xbox FIFA17 DB), with no importer code change.
|
||||
## Implementation
|
||||
- Consumables+staff: NEW emit path in openfut-import-fifa17 (classify already buckets;
|
||||
plan_definitions only ingests PlayerCard). Add definition builders + Core CardDefinition
|
||||
rows to fifa17-production-cards.json + owned instances in apply.rs. Data 100% present.
|
||||
- Legends: attempt read-only .105 Legends locale research; if names recoverable add roster
|
||||
rows (auto-promote). Otherwise document as unrecoverable (do NOT fabricate names).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,198 @@
|
||||
# FIFA 17 Transfer Market — wire findings
|
||||
|
||||
Reverse-engineering record for the FIFA 17 UTAS transfer-market surface, kept so
|
||||
future agents do not reopen settled questions or re-guess enum spellings.
|
||||
|
||||
Every claim carries a confidence tag:
|
||||
|
||||
| Tag | Meaning |
|
||||
|---|---|
|
||||
| **CONFIRMED** | Observed from our own FIFA17.exe client or live host capture |
|
||||
| **FIFA17-HISTORICAL** | Supported by contemporaneous FIFA 17 implementations (`lorenzh/fut-api`, `futapi/fut` v0.2.18 — the last pre-FIFA-18 release) |
|
||||
| **INFERRED** | Best explanation, not directly captured |
|
||||
| **UNKNOWN** | Requires instrumentation; do NOT implement from guesswork |
|
||||
|
||||
Authority reminder: FIFA 17 field names, enum spellings, sentinel ids and
|
||||
empty-state shapes come from captures or the Python oracle — never from a modern
|
||||
FUT toolkit. Later-FIFA API drift is a known hazard, and reversing a container
|
||||
type or inventing an enum is the documented client-freeze class.
|
||||
|
||||
---
|
||||
|
||||
## The auction record (`auctionInfo[]`)
|
||||
|
||||
What we emit today, on `/tradePile`, `/trade/status` and market browse:
|
||||
|
||||
```json
|
||||
{
|
||||
"tradeId": 1000000097,
|
||||
"itemData": { "...full shaped card...": "", "itemState": "listFS" },
|
||||
"tradeState": "active",
|
||||
"buyNowPrice": 15000,
|
||||
"startingBid": 150,
|
||||
"currentBid": 0,
|
||||
"offers": 0,
|
||||
"bidState": "none",
|
||||
"expires": 3600,
|
||||
"tradeOwner": true,
|
||||
"sellerId": 33068179,
|
||||
"sellerName": "CAGE",
|
||||
"sellerEstablished": 1,
|
||||
"watched": false,
|
||||
"coinsProcessed": 0
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Confidence | Note |
|
||||
|---|---|---|
|
||||
| `tradeOwner` (bool) | **FIFA17-HISTORICAL** | Exists in FIFA 17 auctionInfo. That it is *the* Actions-panel gate is **UNKNOWN** pending live confirmation. |
|
||||
| `sellerId` | **FIFA17-HISTORICAL** exists; type numeric is **INFERRED** | Set to the configured persona so it agrees with `tradeOwner`. Never baked in. |
|
||||
| `sellerName` | **CONFIRMED** it must be the player | `fut_account.py` annotates the persona property as "Blaze PDTL.DSNM / LSX GetProfileResponse Persona / **UTAS sellerName**". EA's `"EASFC"` here is wrong for an own listing. |
|
||||
| `offers` | **FIFA17-HISTORICAL** | `0` valid for active/unbid. |
|
||||
| `bidState: "none"` | **FIFA17-HISTORICAL** | Valid for active/unbid. Other observed concepts: `highest`, `buyNow`. Do NOT "fix" this. |
|
||||
| `expires` | **FIFA17-HISTORICAL** | **SECONDS REMAINING, not an epoch.** Historical durations: 3600, 10800, 21600, 43200, 86400, 259200. |
|
||||
| `itemData.itemState: "listFS"` | **UNKNOWN** | Plausible and unchanged. Public FIFA 17 material gives no trustworthy enumeration. Do not guess replacements — capture. |
|
||||
| `itemData.untradeable` | **FIFA17-HISTORICAL** field; our blanket `false` is **INFERRED** | See "Known debt" below. |
|
||||
| `marketDataMinPrice` / `marketDataMaxPrice` | **do NOT add** | These entered the public parser only after its FIFA 18 migration. |
|
||||
|
||||
### Why the differential could not catch the missing fields
|
||||
|
||||
Our record's key set was **identical to the Python oracle's**, so field-for-field
|
||||
parity was green. The oracle omits `tradeOwner` / `sellerId` / `offers` as well,
|
||||
because *its* remove flow was never driven by a real client either — the only
|
||||
historical live datapoint is a counts-tile bug. Oracle parity is therefore
|
||||
**necessary but not sufficient** for any flow the oracle never actually served.
|
||||
|
||||
The differential now asserts we cover every oracle key AND that our extra keys are
|
||||
exactly `{offers, sellerId, tradeOwner}`, so the deliberate superset is pinned
|
||||
while a new unexplained divergence still fails.
|
||||
|
||||
---
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Confidence | Behaviour |
|
||||
|---|---|---|
|
||||
| `GET …/trade/status` | **CONFIRMED** the client polls it continuously | Live auction-state refresh. It previously fell through `starts_with("trade")` into the buy/view arm, where the tail has no numeric id, so **every poll returned `{"auctionInfo": []}`**. Now a real handler: optional `tradeIds` filter, else the whole active pile. Unknown ids are absent, never an error. |
|
||||
| `DELETE /ut/game/<sku>/trade/<id>` | **FIFA17-HISTORICAL** | The spelling contemporaneous FIFA 17 clients use, no body, no meaningful response body. Previously landed in the buy/view arm and **silently cancelled nothing while returning 200.** Now maps to MarketCancel. |
|
||||
| `DELETE /ut/delete/game/<sku>/trade/<id>` | **CONFIRMED** (oracle) | The oracle's spelling; retained because the differential exercises it. Whether FIFA17.exe ever uses it is **UNKNOWN**. |
|
||||
| `POST …/auctionhouse` | **CONFIRMED** | List for sale. The client sends only `itemData.id`; the server resolves wire id → Core instance → `card_id`/`resourceId` and enforces ownership. |
|
||||
| `GET …/tradePile/counts` | **INFERRED** | Five scalar ints (`count`, `maxAuctionsAllowed`, `offered`, `selling`, `sold`); a DISTINCT deserializer from `/tradePile`. Exact FIFA 17 semantics of `count` (active auctions vs whole pile) is **UNKNOWN** — we report active auctions and deliberately did NOT speculate. |
|
||||
| `PUT …/item` (move) | **FIFA17-HISTORICAL** | `{"itemData":[{"pile":"trade"|"club","id":ID}]}` → `{"itemData":[{id,pile,success}]}`. Transfer-List membership is a **separate operation from creating an auction**. |
|
||||
|
||||
### Pile encoding
|
||||
|
||||
* MOVE commands take a **string** pile (`"trade"`, `"club"`) — **FIFA17-HISTORICAL**.
|
||||
* Returned `itemData.pile` is documented **numeric** in FIFA 17 auction data — **FIFA17-HISTORICAL**.
|
||||
* The numeric mapping is **UNKNOWN**. Do not unify the two representations, and do
|
||||
not derive a mapping from unrelated `pileSize` keys.
|
||||
|
||||
---
|
||||
|
||||
## Q2 — Transfer List item that is not currently auctioned
|
||||
|
||||
A real FUT state: an item in the Transfer List with no active auction (freshly
|
||||
moved, or expired unsold). **CONFIRMED** to exist as a concept (the external hub
|
||||
spec §27, and move-vs-list being separate operations).
|
||||
|
||||
Its wire representation is **UNKNOWN**: `tradeId` 0 / omitted / null, `tradeState`
|
||||
value or omission, and `itemData.itemState` are all unestablished.
|
||||
|
||||
Consequence, and the reason this matters: our `/tradePile` renders only `active`
|
||||
listings, so keying the `/club` exclusion on the `trade` **pile** stranded 4 cards
|
||||
in no screen at all (hidden from the club, absent from the Transfer List).
|
||||
Commit `f2c4927` keys exclusion on the **active listing** instead, which is
|
||||
self-healing. That is a workaround, not fidelity — the faithful model needs the
|
||||
unlisted state represented.
|
||||
|
||||
**Required capture** (four states, full structural diff, not just a shortlist):
|
||||
|
||||
```
|
||||
A. moved Club -> Transfer List, NEVER listed
|
||||
B. actively listed
|
||||
C. listing expired unsold
|
||||
D. listing sold
|
||||
```
|
||||
|
||||
Diff at least: `tradeId`, `tradeOwner`, `tradeState`, `bidState`, `expires`,
|
||||
`offers`, `currentBid`, `startingBid`, `buyNowPrice`, `sellerId`, `sellerName`,
|
||||
`itemData.id`, `itemData.itemState`, `itemData.pile`, `itemData.untradeable`.
|
||||
|
||||
Do NOT drop the unlisted state from the model just because its encoding is unknown.
|
||||
|
||||
---
|
||||
|
||||
## Deferred, with reasons
|
||||
|
||||
* **5% transfer tax** — **INFERRED** architecture only: auction closes → Core
|
||||
settles → seller credited gross × 0.95, with `auctionInfo` continuing to carry
|
||||
gross. No trustworthy FIFA 17 field named `tax`/`netPrice`/`sellerProceeds` was
|
||||
recovered, and no separate settle operation. Not blocking; do not couple
|
||||
settlement to clearing the sold auction without a capture.
|
||||
* **Bid / Transfer Targets** — not implemented. Watched / active bid / winning /
|
||||
outbid / won / expired are distinct states and must not collapse to a flat list.
|
||||
* **Unassigned** — FIFA 17 had a dedicated Unassigned service; the exact FIFA 17
|
||||
URL is **UNKNOWN**. Our 29-item `purchased` pile is this state and is currently
|
||||
rendered inside `/club`. Do not manufacture a route from a modern toolkit.
|
||||
* **Match CREATE / READY / PLAY** — **UNKNOWN** and explicitly not portable from
|
||||
public FUT web-app work (the web app could not start matches). Instrument the
|
||||
real client from Play Match to kickoff before implementing.
|
||||
|
||||
---
|
||||
|
||||
## Known debt
|
||||
|
||||
`shape_item` reports `untradeable: false` for **every** owned instance. Correct
|
||||
today (Core models no untradeable items) and necessary — a hardcoded `true` greyed
|
||||
out both list buttons — but it will misrepresent SBC / promo / loan rewards once
|
||||
those exist. `untradeable` belongs on the owned-item instance as authoritative
|
||||
state, not inferred from definition, resourceId or rarity.
|
||||
|
||||
---
|
||||
|
||||
## MEASURED in the live client — 2026-08-17
|
||||
|
||||
Read out of the running `FIFA17.exe` (pid-resolved, CardsDLL slide proven against
|
||||
the on-disk FNV prologue) with `fifa17-recon/tools/trade_gate_probe.py`, which
|
||||
extends `gate_byte_probe.py` to vtable slot `+0x270` as the transfer-market
|
||||
analysis asked for. Read-only: `/proc/<pid>/mem` `O_RDONLY` + `pread`.
|
||||
|
||||
| Gate | Python era (2026-08-06) | Now | Owner |
|
||||
|---|---|---|---|
|
||||
| `IS_TRADING_ENABLED` `model+0x1fd2e` (slot `+0x270`) | **0** | **1** | settings struct `+0x28`; was zeroed by `userInfo.feature.trade` |
|
||||
| `TRADE_PILE_SIZE` `model+0x1fd1c` | **0** | **100** | `userMassInfo.pileSizeClientData` key 2 |
|
||||
| watch-list size `model+0x1fd20` | **0** | **50** | same member, key 4 |
|
||||
| `storeEnabled` `model+0x1fd2f` | 1 | 1 | control |
|
||||
| `IS_FRIENDLY_SEASON` / `IS_DRAFT_MODE` / `packOpeningAnimation` | 1 | 1 | controls |
|
||||
|
||||
**CONFIRMED: every CardsDLL-supplied input the transfer-market analysis named as a
|
||||
blocker is now open.** The Rust host does this by construction — it emits
|
||||
`userInfo.feature` as `{}` (no `trade` member, so the kill switch at `0x180174f19`
|
||||
never arms: it fires only when atom `0x330` inside `0x11c` parses as exactly 1) and
|
||||
it already sends `pileSizeClientData` keys 2 and 4. Serving `tradingEnabled: 1` in
|
||||
the settings `configs` array would NOT have worked, because that tail runs after
|
||||
every member is parsed and would overwrite it.
|
||||
|
||||
### What this rules out
|
||||
|
||||
The Transfer List Actions panel not opening on an own listing is therefore **not**:
|
||||
|
||||
* an ownership field — FIFA 17's auctionInfo has no `tradeOwner`/`sellerId` atom;
|
||||
* `IS_TRADING_ENABLED`, `TRADE_PILE_SIZE` or the watch-list size — all measured open;
|
||||
* the cancel route — `DELETE ut/delete/{ns}/trade/{tradeId}` is the PE's spelling and
|
||||
is what we serve;
|
||||
* `tradeState` / `bidState` / `expires` spellings — all three are the PE's own
|
||||
vocabularies and values.
|
||||
|
||||
Per the analysis's own falsifier ("if the byte reads 1 and the screen still refuses,
|
||||
the exe-side predicate has a term we have not enumerated"), the remaining term is
|
||||
**exe-side UI script**, which CardsDLL does not own and the server cannot set.
|
||||
Status: **UNKNOWN**, and it is now the narrowest it has ever been.
|
||||
|
||||
### Confirmed fidelity bug found on the way
|
||||
|
||||
`expires` was a frozen `3600` on every poll, so the client's live countdown never
|
||||
moved and an auction could never run out. Now derived from `created_at + duration`
|
||||
(duration taken from the `ISStart` body), clamped at 0, with an aged-out active
|
||||
listing projecting as `expired`/`none` — FIFA 17's relistable state. Verified live:
|
||||
the standing listing correctly reads `expires: 0` once past its hour.
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Overnight session handoff — 2026-08-17
|
||||
|
||||
Autonomous session while you slept. Low-ceremony per your instruction. Everything
|
||||
below is verified as noted; nothing was committed or pushed (see
|
||||
[Uncommitted work](#uncommitted-work--needs-your-review)).
|
||||
|
||||
## TL;DR
|
||||
|
||||
1. **Production promotion completed** (you chose "promotion"): prod-host swapped to the
|
||||
post-P1 build, content-gap imported. Then I migrated the **remaining Python UTAS
|
||||
routes that have a known contract** to Rust: account/sync, ut/auth (SID mint),
|
||||
userMassInfo (full), clientdata, club/stats/{country,league,team}, and the trivial
|
||||
static acks. The client's **observed FUT-hub/economy flow is now fully Rust**; a
|
||||
tail of lower-traffic routes **without a captured wire shape** (item-defs,
|
||||
user-identity, watchList/marketdata, non-active `squad/<n>`, draft, and mode-gated
|
||||
season/tournament/champion/leaderboards/sbs) **still proxy to the Python oracle**.
|
||||
2. **Launcher redesigned to a shareholder-grade egui UI** (your headline ask). Builds
|
||||
clean; screenshots captured.
|
||||
3. **New docs:** `MATCH_LIFECYCLE.md` (you asked), `CORE_CORRECTNESS_ISSUES.md`
|
||||
(4 known Core bugs + 1 bonus exploit, ready to fix on your go/no-go).
|
||||
4. **Nothing committed** — all work is in the working tree for your review (git state is
|
||||
delicate: preserved-dirty Core submodule + a concurrent `funman300` actor + detached
|
||||
launcher branch; I didn't want to entangle that unsupervised).
|
||||
|
||||
## What's live in production now (`10.10.0.120:8099`)
|
||||
|
||||
| Thing | State |
|
||||
|---|---|
|
||||
| prod-host binary | post-P1 `fda40d12` **+ my migration rebuild** (release, in `target/release/openfut-utas-host`) |
|
||||
| prod-host pid | 3207781 (hub-managed, restart=no; retained spec points at the rebuilt binary) |
|
||||
| Catalog | `9f6addaa` (post-P1) |
|
||||
| Core content (cards) | `136d8d68` (post-P1, +18 content-gap defs) → Core loads **1710** defs |
|
||||
| Core owned | **1982** (1962 players + 17 consumables + 3 staff) |
|
||||
| Coins | **29,876,776** (baseline — reset from the P1 test value when the content-gap DB was swapped in; you said data isn't precious) |
|
||||
| prod-core | **fixed build** from canonical submodule (`fbb54ea` + 4 correctness fixes), DB migrated ver 18 → 19; binary now `/home/alex/OpenFUT/target/release/openfut-core` |
|
||||
| UTAS routes (Rust) | economy, club, squad (0/active/list/PUT), account/sync, ut/auth, userMassInfo, **user**, clientdata, hub, settings, accountinfo, leaderboards/options, match/reset, phishing, club/stats/{year,consumables,staff,country,league,team}, watchList, static acks (store/keepalive/captcha/tfa/livemessage/activeMessage) |
|
||||
| Still Python (:8199) | item-defs (item/resource, defid), club-identity (clubUser, user/list, user/club), `squad/<n>` (n≠0), draft, marketdata, mode-gated (season/tournament/champion/leaderboards/sbs → `{}` while off), and match CREATE/READY/PLAY. See `docs/PRODUCTION_AUTHORITY_MATRIX.md`. |
|
||||
|
||||
Smoke-verified live in prod (in the prod netns): all migrated routes return
|
||||
`owner=RUST`, coins consistent, clientdata round-trips, club/stats context modes emit
|
||||
distinct nation/league/team buckets. Scripts:
|
||||
`/home/alex/openfut-promotion/economy-2026-08-17-p2/{p2_precheck,smoke_migrated,smoke_clubstats}.py`.
|
||||
|
||||
## Changes made (all verified: builds clean, tests green)
|
||||
|
||||
### 1. Production promotion (deployed)
|
||||
- Host binary `e5be8730` (P1) → `fda40d12` (post-P1) + catalog `9f6addaa`.
|
||||
- Content-gap DB swapped in (owned 1962 → 1982). Backups in
|
||||
`/home/alex/openfut-promotion/economy-2026-08-17-p2/backup/` + `ROLLBACK.txt`.
|
||||
|
||||
### 2. Route migration to Rust (deployed, rebuilt binary)
|
||||
- `POST /ut/auth` — Rust mints the SID (`OPENFUT-SID-{:016X}`), opens the Rust session,
|
||||
adopts persona from body. No Python. (`+ /ut/delete/auth`.)
|
||||
- `POST /openfut/account/sync` — full Rust envelope; coins/unopenedPacks from Core.
|
||||
- `GET /userMassInfo` — **full** Rust envelope (was a Python-proxy+overlay hybrid).
|
||||
- `GET/PUT /clientdata/<key>` — new host `ClientDataStore` (JSON-persisted).
|
||||
- `GET /club/stats/{country,league,team}` — made `club_stats_body` context-aware
|
||||
(nation/league/team buckets); classify now routes all `club/stats/*` to Rust.
|
||||
- `GET /squad/0` — routed to the Rust active-squad projection (verified structurally
|
||||
identical to Python `squad/0`: same 15 keys, players=23).
|
||||
- `GET /watchList` (+ no-op add/remove) — empty list + authoritative Core credits.
|
||||
- Static acks (`store`, `match/keepalive`, `captcha`, `tfa`, `livemessage`,
|
||||
`activeMessage`) — Rust constants (StaticAck route), byte-identical to the oracle.
|
||||
- Captured the remaining routes' Python wire shapes as reference fixtures for later
|
||||
migration: `docs/evidence/route-shapes-2026-08-17/` (user, defs, marketdata,
|
||||
clubUser, watchList, squad/0, season/tournament/champion/sbs, draft).
|
||||
- Files: `openfut-utas-host/src/{lib.rs,clientdata_store.rs(new),config.rs}`,
|
||||
`openfut-adapter-fifa17/src/fut/{non_economy.rs,club_stats.rs}`,
|
||||
`openfut-utas-host/tests/economy_integration.rs`.
|
||||
- Tests: `openfut-utas-host` + `openfut-adapter-fifa17` full suites **GREEN**
|
||||
(188 adapter + 76 host lib + all integration incl the 116s economy integration).
|
||||
|
||||
### 3. Launcher redesign + polish + live account panel (built, NOT deployed — client tool)
|
||||
- **Redesign**: new `openfut-launcher/src/theme.rs` design system (palette, embedded
|
||||
fonts, egui Visuals/Style, card/pill helpers). Branded hero header (OF monogram),
|
||||
left nav rail, card-based dashboard with status pills, prominent accent Launch CTA,
|
||||
console-style Logs. All existing launch/health/preflight/service/config logic preserved.
|
||||
- **Polish**: OpenFUT window/taskbar icon (OF monogram `IconData`), Config tab rebuilt
|
||||
into themed cards, consistency sweep.
|
||||
- **Live "Your Club" panel** (new feature): a background `AccountMonitor` (mirrors
|
||||
`HealthMonitor`, 5s poll, non-blocking) fetches the account summary and the Dashboard
|
||||
shows a "Your Club" card — club name/abbr, Manager, **COINS hero number**, Level + XP
|
||||
bar, unopened packs, account funds — with clean loading/offline/error states.
|
||||
- Builds clean (0 warnings). Screenshots preserved (for your shareholder demo) in
|
||||
`/home/alex/openfut-post-p1/launcher-screenshots-2026-08-17/` — `launcher_account.png`
|
||||
(the populated "Your Club" card: COINS 29,876,776, Level 12, packs 3) is the headline;
|
||||
plus dashboard/setup/logs/config + the offline state. All reviewed — product-quality.
|
||||
- Files: `openfut-launcher/src/{theme.rs(new),account_monitor.rs(new),app.rs,main.rs,
|
||||
account_sync.rs,config.rs}` + `assets/` (fonts + icon). All additive; behavior preserved.
|
||||
|
||||
### 4. Core correctness fixes (deployed 2026-08-17)
|
||||
|
||||
All four `CORE_CORRECTNESS_ISSUES.md` classes fixed in the canonical `openfut-core`
|
||||
submodule and deployed to prod-core (see that doc's "Resolution" section):
|
||||
- **Issue 3 (HIGH, exploit):** SBC duplicate-card free-reward — `submit_sbc` now dedups
|
||||
ids + bounds the list (`MAX_SBC_CARDS`=30) → `BadRequest`. Regression test added.
|
||||
- **Issue 2 (HIGH):** non-atomic economy mutations — closed the concurrency-exploit
|
||||
races with single-statement compare-and-swap (`spend_coins` conditional debit,
|
||||
`open_pack`/`buy_listing`/`sell_card`/`checkin` claim-then-act). Multi-statement
|
||||
partial-failure edges (`buy_pack`, concurrent SBC, match/season chains) left as
|
||||
documented residual — need the transaction refactor, negligible for single-player.
|
||||
- **Issue 4 (LOW):** `season.rs` `.expect()` panics → graceful `AppError`; checkin index
|
||||
`% 7` → `.rem_euclid(7)`.
|
||||
- **Issue 1 (LOW):** `sbc_submissions.club_id` — migration `0019` (add + backfill) +
|
||||
`submit_sbc` binds it; milestone query now correct.
|
||||
- Verified: Core suite **179 green** + clippy clean; migration dry-run on a prod-DB copy;
|
||||
post-deploy prod migration ver 19, owned 1982, coins 29,876,776, all Core + host
|
||||
endpoints 200. Rollback: `backup/prod-core.preCoreFix.db` (ver 18) + old binary path —
|
||||
see `backup/ROLLBACK_CORE_FIX.txt`.
|
||||
|
||||
## New / updated docs
|
||||
|
||||
- `docs/MATCH_LIFECYCLE.md` (NEW) — consolidated FUT match loop design (CREATE→READY→
|
||||
PLAY→END), contracts, reward policy, ownership split, blockers. (You asked for this.)
|
||||
- `docs/CORE_CORRECTNESS_ISSUES.md` (NEW) — 4 known Core bugs + 1 bonus SBC
|
||||
duplicate-card exploit, each with file:line + concrete fix + severity. **Needs your
|
||||
go/no-go** (fixing bumps Core off the frozen P1 reference).
|
||||
- `docs/PRODUCTION_AUTHORITY_MATRIX.md` (UPDATED) — reflects the completed migration.
|
||||
- Vault `06 Agent Memory/Current Priorities.md` (UPDATED).
|
||||
|
||||
## Uncommitted work — needs your review
|
||||
|
||||
**I committed nothing** (git state is delicate: openfut-core is intentionally
|
||||
preserved-dirty; a concurrent `funman300` actor; launcher on detached HEAD `d1a71bd`).
|
||||
Review + commit these when you're ready:
|
||||
|
||||
- Superproject (mine): `openfut-utas-host/src/{lib.rs,config.rs}`,
|
||||
`openfut-utas-host/src/clientdata_store.rs`,
|
||||
`openfut-utas-host/tests/{economy_integration.rs,host_test.rs}`,
|
||||
`openfut-adapter-fifa17/src/fut/{club_stats.rs,non_economy.rs}`,
|
||||
`docs/{PRODUCTION_AUTHORITY_MATRIX.md,MATCH_LIFECYCLE.md,CORE_CORRECTNESS_ISSUES.md,OVERNIGHT_HANDOFF_2026-08-17.md,PYTHON_RETIREMENT_PLAN.md}`,
|
||||
and the reference fixtures `docs/evidence/route-shapes-2026-08-17/`.
|
||||
- Launcher submodule (mine): `src/{app.rs,main.rs,theme.rs(new),account_monitor.rs(new),account_sync.rs,config.rs}`, `assets/` (fonts + icon).
|
||||
- **Leave the pre-existing dirt alone** (not mine): `CLAUDE.md`, `README.md`,
|
||||
`.env.example`, `docker-compose.yml`, `AGENTS.md`, `setup.sh`, `openfut-bridge`,
|
||||
`fifa17-recon/docker/...`, `docs/{ARCHITECTURE,ROADMAP,docker,fifa17-emulation}.md`,
|
||||
`docs/research/`, `scripts/utas-filter-diff.py`.
|
||||
- `openfut-core` (mine, this session): `migrations/0019_sbc_submissions_club_id.sql` (new),
|
||||
`src/services/{sbc.rs,club.rs,pack.rs,market.rs,checkin.rs,season.rs}`,
|
||||
`tests/integration_test.rs`. Built + deployed to prod-core; still detached HEAD at
|
||||
`fbb54ea` (edits uncommitted, per your branch strategy).
|
||||
|
||||
## Open decisions for you
|
||||
|
||||
1. ~~**Core correctness bugs**~~ — **DONE (2026-08-17):** all four classes fixed +
|
||||
deployed to prod-core (see "Core correctness fixes" above and the Resolution section
|
||||
of `docs/CORE_CORRECTNESS_ISSUES.md`). prod-core is now off the frozen P1 point,
|
||||
running `fbb54ea` + fixes at migration ver 19. Residual (documented): the
|
||||
multi-statement transaction refactor for partial-failure atomicity — negligible for
|
||||
single-player; do it if/when concurrency matters.
|
||||
2. **Match handshake legs** (CREATE/READY/PLAY) — the only routes still on Python.
|
||||
Deferred: no match has ever been played in-game, READY `items` contract unknown,
|
||||
`FUT_MODES` off (see `docs/MATCH_LIFECYCLE.md`). Migrating them risks the economy
|
||||
`/match/end` routing for a never-exercised path — I judged it not worth it unmonitored.
|
||||
3. **Aux service container cutover** (blaze/redirector/roster → Rust) — operator-gated
|
||||
container change; unchanged.
|
||||
|
||||
## Rollback (still hot)
|
||||
|
||||
- Python P2 image `openfut-fut-backend:p2-rollback` (b1b929953f) + profile 39bb3e83 +
|
||||
`rollback-to-python-p2.sh`.
|
||||
- prod-host P1 binary + P1 catalog backed up in
|
||||
`/home/alex/openfut-promotion/economy-2026-08-17-p2/backup/` (see `ROLLBACK.txt`).
|
||||
- prod state (pre-content-gap) backed up: `backup/prod-core.preB.db`,
|
||||
`prod-identity.preB.json`, plus P1 `fifa17-production-{catalog,cards}.p1.json`.
|
||||
@@ -0,0 +1,165 @@
|
||||
# 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,
|
||||
real Client A session 2026-08-14), `openfut-utas-host/ROUTE_AUTHORITY.md`, and the
|
||||
prod container config (`OPENFUT_SERVERS="blaze roster pow"`).
|
||||
|
||||
Evidence labels: OBSERVED (live log/db), PROVEN (test), INFERRED, HYPOTHESIS.
|
||||
|
||||
Legend: owner R = Rust/Core, P = Python oracle (:8199 proxied via PYTHON_FALLBACK).
|
||||
|
||||
## UTAS HTTP (front door: openfut-utas-host :8099)
|
||||
|
||||
### ECONOMY — already Rust (Python economy hits = 0, OBSERVED)
|
||||
| Method/path (tail) | Prod owner | Writes state | Rust handler | Py proxy |
|
||||
|---|---|---|---|---|
|
||||
| GET /user/credits | R | no | handle_credits | NO |
|
||||
| GET /store/purchasegroup[/all] | R | no | handle_purchasegroup | NO |
|
||||
| PUT /store/transaction[/<id>] | R | coins,inv,pile | handle_store_buy | NO |
|
||||
| POST /purchased[/items] | R | coins,inv,ent,pile | handle_pack_open | NO |
|
||||
| GET /purchased[/items] | R | no | shape_purchased_reveal | NO |
|
||||
| DELETE /item/<id> | R | coins,inv | handle_quick_sell_path | NO |
|
||||
| POST /ut/delete/../item | R | coins,inv | handle_quick_sell_body | NO |
|
||||
| PUT /item | R | inv,pile | handle_move_items | NO |
|
||||
| POST /ut/delete/../match | R | coins | handle_match_end | NO |
|
||||
| POST /auctionhouse,/transfermarket | R | listings | handle_market_list | NO |
|
||||
| GET /tradePile | R | no | handle_market_query | NO |
|
||||
| GET /tradePile/counts | R | no | handle_market_counts | NO |
|
||||
| /trade/<id> (POST/PUT/GET) | R | coins,inv,listings | handle_market_buy | NO |
|
||||
| DELETE /ut/delete/../trade/<id> | R | listings | handle_market_cancel | NO |
|
||||
|
||||
**Transfer-market semantics (live-verified 2026-08-17).** Three things here are
|
||||
load-bearing and were each a live defect:
|
||||
1. `/tradePile` and `/tradePile/counts` are **different deserializers** and MUST NOT
|
||||
share a handler. `/counts` is FutGetAuctionCount: five scalar ints
|
||||
(`count`, `maxAuctionsAllowed`, `offered`, `selling`, `sold`) and nothing else.
|
||||
Served the `auctionInfo` body it skips every field, leaving the counts at 0 — the
|
||||
hub tile shows a listing while the Transfer List screen shows no active sale.
|
||||
2. An auction record's `itemData` MUST be the **full card object** (the same shape
|
||||
`/club` emits), not a stub. A listing therefore persists a shaped-card SNAPSHOT
|
||||
(`listings.item_json`) at list time. The seller's own pile stamps
|
||||
`itemState: listFS`; market search uses `forSale`.
|
||||
3. `POST /auctionhouse` resolves the listed card **server-side** from the wire item
|
||||
id (`wire → Core instance → card_id + resourceId`); the client's FutISStart body
|
||||
carries only the id. This also enforces that you can only list what you own.
|
||||
|
||||
### NON-ECONOMY — Rust-owned (route migration 2026-08-17, OBSERVED prod log)
|
||||
| Route | Owner | Notes |
|
||||
|---|---|---|
|
||||
| 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 /club, /club/* readers | R | Core-backed collection (dropped_no_asset=0). Cards with an **ACTIVE listing are excluded** — a listed card has left the club. Keyed on the listing, NOT on the `trade` pile: the pile can hold cards with no listing (bare move, or cancelled/sold) and `/tradePile` renders only ACTIVE listings, so hiding the pile would make those invisible in BOTH views. Keying on the listing is self-healing — cancel/sale restores club visibility with no extra transition. Pagination then runs over the club-visible set (Core cannot filter host-owned listing state, so letting it paginate would yield short pages); with nothing hidden the fast Core-paginated path is unchanged. |
|
||||
| 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; `clubPlayers` excludes actively-listed cards, `auctionCount`/`tradePile` from the durable market store |
|
||||
| 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) |
|
||||
| GET /season, /tournament, /champion, /clubUser, /user/list | R | FUT modes + club-identity off → `{}` (FeatureOffEmpty; byte-identical to the flag-off oracle). Migrated + deployed 2026-08-17 |
|
||||
| POST /ut/.../match/end (DestroyMatch) | R | economy reward (coins credited via Core grant_reward) |
|
||||
| GET /item/resource, /defid | R | `{itemData:[item_def…]}` — asset=rid&0xffffff; hardcoded Ronaldo (20801) + placeholder ("Player",75,CM,attrs 70), mirroring the oracle's `item_def`. Client renders from its LOCAL DB, so the placeholder is exact parity. Migrated + deployed 2026-08-17 |
|
||||
| GET /marketdata, /marketdata/pricelimits | R | suggested pricing, constant band 150..15000. `/pricelimits` = bare ARRAY (one per defId); plain `/marketdata` = OBJECT — container type is load-bearing (object-where-array froze a live client). Migrated + deployed 2026-08-17 |
|
||||
|
||||
### NON-ECONOMY — still Python (PYTHON_FALLBACK)
|
||||
| Method/path | Owner | Reason |
|
||||
|---|---|---|
|
||||
| POST /user/club (rename) | P | mutating club rename; Core has `clubs` but rename needs a Core write (deferred). Reads `clubUser`/`user/list` are now Rust. |
|
||||
| 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 /leaderboards, /sbs/* | P | mode-gated (FUT_MODES/_SBC off → `{}`/content; `/sbs/sets` ships content); real behavior needs the mode logic ported. `season`/`tournament`/`champion` are now Rust. |
|
||||
| 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
|
||||
`blaze_responder_v3b.py`/`roster_server.py`/`pow_server.py`). Rust equivalents exist
|
||||
outside Docker; deploying them is operator-gated (production deployment forbidden here).
|
||||
|
||||
| Service | Rust crate | Completeness | Prod owner | Reachable | Blocker to candidate |
|
||||
|---|---|---|---|---|---|
|
||||
| Blaze (:42130) | openfut-blaze-host + openfut-protocol-blaze + adapter::blaze | COMPLETE, gate-proven to real FUT (Gate 10: 3 logins, 10 pack opens, 448/448 py suite) | Python | yes | ERRC error-reply placement unresolved; wire into candidate bring-up |
|
||||
| Redirector (:42127 TLS) | openfut-redirector-host + openfut-tls (OpenSSL vendored) | COMPLETE, 1 live handshake 2026-08-11 (TLSv1.2/AES256-GCM-SHA384) | Python | yes | never full-path gated; cert consistency |
|
||||
| Roster (:8081) | openfut-roster-host | COMPLETE, oracle-parity + lifecycle tests, unit-only | Python | yes | never live-gated |
|
||||
| POW (:8094 + :8080) | NONE (only pow_server.py) | no Rust host; 58 templates, bodies placeholder | Python | yes | LARGEST: no crate + bodies un-reversed |
|
||||
| Nucleus (:42131) | none (advertised string only) | n/a | Python stub (advertised, unused) | NO (0 live hits) | DEAD in current flow — keep advertising URL, no migration |
|
||||
|
||||
RETIRED/out-of-scope: fifa-blaze (FIFA23 capture stub), openfut-bridge (FIFA23). Not in FIFA17 flow.
|
||||
|
||||
## NON-ECONOMY UTAS TARGET OWNER (confirmed via scouts)
|
||||
Core already exposes GET/PUT /settings, GET /club|/collection|/statistics|/profile, /squad/*.
|
||||
New Rust arm = Route variant + classify() arm (lib.rs:118-140) + owner-labelled handler.
|
||||
|
||||
| Route | Port complexity | Target | Notes |
|
||||
|---|---|---|---|
|
||||
| user/accountinfo | trivial-static ({}) | R | host constant |
|
||||
| settings | trivial-static ({"configs":[]}) | R | host constant / Core /settings |
|
||||
| leaderboards/options | trivial-static | R | host constant |
|
||||
| match/reset | trivial-static ({}) | R | host constant ack |
|
||||
| phishing/trusteddevice | small (validate hex + constant) | R | security-question: stateless ack, always verified/trusted |
|
||||
| clientdata/userHubData | small stateful | R | Core PUT /settings upsert keyed userHubData |
|
||||
| hub | medium (derived counts) | R | Core collection + market counts |
|
||||
| club/stats/year | medium (rating-tier aggregation) | R | Core /collection + /statistics |
|
||||
| club/stats/consumables | medium | R | BLOCKED on 17-consumable import |
|
||||
| club/stats/staff | trivial ({}) or derived | R | BLOCKED on 3-staff import |
|
||||
| account/sync | small-medium (persona select + save) | R | hardest: no direct Core route; host session/persona logic |
|
||||
| ut/auth envelope | small (SID mint) | R (currently OBSERVE) | Python still mints envelope; boundary decision |
|
||||
|
||||
## MIGRATION PRIORITY ORDER (Phase 12)
|
||||
1. Content gap consumable+staff emit (unblocks club/stats/{consumables,staff}) — import crate.
|
||||
2. Trivial-static host routes: accountinfo, settings, leaderboards/options, match/reset, phishing/trusteddevice.
|
||||
3. clientdata/userHubData (Core settings upsert).
|
||||
4. hub + club/stats/year (Core-derived aggregation).
|
||||
5. userMassInfo full ownership (envelope scaffold; econ+squad already Rust).
|
||||
6. account/sync + ut/auth envelope (account/session boundary).
|
||||
7. Aux candidate wiring (blaze/roster/redirector already built) + POW (blocked) + Nucleus (dead).
|
||||
|
||||
## ECONOMY EXPECTATION
|
||||
Python economy hits = 0 (OBSERVED live + PROVEN by NEVER-BOTH/no-fallback tests).
|
||||
Any nonzero Python economy hit = P1 regression, priority zero.
|
||||
|
||||
## STATUS
|
||||
Baseline + economy + Rust-owned non-economy rows: OBSERVED/PROVEN.
|
||||
Aux + non-economy target owners: confirmed via HostSourceMap/PythonContractMap/AuxServiceMap/ContentGapMap scouts.
|
||||
|
||||
## CANDIDATE MIGRATION STATUS (post-P1 progress, off-production)
|
||||
DONE (Rust-owned in candidate source; committed on top of 5020137; workspace tests green):
|
||||
- Content gap: consumable+staff emit (20 instances / 18 defs; verified real-profile re-import 1962+20).
|
||||
- Non-economy routes migrated to Rust: user/accountinfo, settings, leaderboards/options,
|
||||
match/reset, phishing/{trusteddevice,question,validate}, club/stats/staff, hub,
|
||||
club/stats/{year,consumables}.
|
||||
(hub clubPlayers = owned player count from Core; may be < Python profile count by the deferred
|
||||
Legend instances = DIFFERENT-BY-DESIGN.)
|
||||
- Reachability reporter (scripts/openfut-reachability.py) gates Python-hit invariants.
|
||||
|
||||
RESIDUAL PYTHON (still proxied; each has a concrete blocker, not ordinary difficulty):
|
||||
- club/stats/{country,league,team} — RESIDUAL (nation/league/team context sub-screens). The global
|
||||
MY-CLUB stat set (club/stats/{year,consumables,staff}) is now Rust (adapter club_stats.rs faithful
|
||||
port of fut_club_stats.py VOCAB + counts, Core-accurate over real imported content, staging-verified
|
||||
RUST route=club-stats owned=1982 players=1962). The per-nation-bucket context sub-screens still proxy
|
||||
Python (low value, unrecognized atoms inert); migrate with a live context capture if ever needed.
|
||||
- account/sync — launcher-facing (POST /openfut/account/sync, pre-auth); envelope has fields not in
|
||||
Core (clubAbbr, established, profilePath, accountFunds cap); changing it risks the launcher. Needs
|
||||
a Core account endpoint or host account logic + launcher-contract verification.
|
||||
- userMassInfo — SAFE hybrid today (Rust overlays economy+squad on Python envelope). Full ownership
|
||||
needs a real full-envelope capture first (missing one scaffold field breaks a hot route); deferred.
|
||||
- ut/auth — ORDERING-BLOCKED: still-proxied Python routes rely on Python's session table, so auth
|
||||
must stay proxied (Python mints SID, Rust OBSERVEs) until every session-needing route is Rust. Migrate LAST.
|
||||
- clientdata/userHubData — BLOCKED: Core /settings supports only 2 fixed keys (difficulty,
|
||||
preferred_formation); storing an arbitrary blob needs a frozen-Core change or a new host store.
|
||||
Low-value UI-pref blob; kept Python per Phase 23 (no Python-by-ideology).
|
||||
|
||||
AUX (Rust built, deploy = operator-gated container cutover, NOT this task): blaze/roster/redirector.
|
||||
POW = blocked (no crate, bodies un-reversed). Nucleus = dead (0 live hits).
|
||||
@@ -0,0 +1,45 @@
|
||||
# Python Retirement Readiness (post-P1)
|
||||
|
||||
Classification of every Python component still present. Evidence: prod-host
|
||||
`owner=` dispatch log (OBSERVED), AuxServiceMap/PythonContractMap scouts,
|
||||
container `entrypoint.sh`. **P2 rollback stays hot regardless** (do NOT remove
|
||||
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 | **remaining**: user/club rename, non-active squad/<n>, draft/*, mode-gated leaderboards + /sbs/* | **Most non-economy migrated 2026-08-17** (account/sync, auth, userMassInfo, user, clientdata, hub, club/stats/*, settings, accountinfo, phishing, match/reset, leaderboards/options, watchList, static acks, item-defs (item/resource,defid), marketdata (+/pricelimits), and the flag-off empty reads season/tournament/champion/clubUser/user/list → `{}` — all Rust). See PRODUCTION_AUTHORITY_MATRIX | remaining tail is mutation (user/club), no-Core-model (squad/<n>), or unimplemented modes (draft/leaderboards/sbs) |
|
||||
| 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 |
|
||||
| pow_server.py (:8094/:8080) | Proof-of-Work / content | NO Rust crate; 58 templates, bodies un-reversed | needs Rust host + body RE (BLOCKED) |
|
||||
|
||||
## B. ORACLE ONLY (reference / differential, not production authority)
|
||||
- utas_server.py economy handlers: now oracle-only (economy is Rust; Python economy hits = 0). Used by
|
||||
`economy_differential.rs` as the parity oracle. KEEP.
|
||||
- fut_store.py / fut_accounts.py / fut_consumables.py: reference models + Ghidra-derived taxonomy source. KEEP.
|
||||
|
||||
## C. MIGRATION TOOL ONLY (offline)
|
||||
- scripts/*.py (seed_fifa17_cards.py, utas-observe/mutate/diff, check-*). KEEP (dev tooling).
|
||||
- fifa17-recon/tools/db_dump.py etc.: table extraction. KEEP.
|
||||
|
||||
## D. ROLLBACK ONLY (hot, DO NOT REMOVE)
|
||||
- rollback-to-python-p2.sh; image openfut-fut-backend:p2-rollback (b1b929953f) + :dev; profile 39bb3e83;
|
||||
compose/env backup; tuple OPENFUT_SERVERS="blaze roster utas pow".
|
||||
|
||||
## E. DEAD (advertised but not followed in current FIFA17 flow)
|
||||
- Nucleus /connect/token stub (:42131): 0 live hits across Gate 5-10 (client never POSTs). Keep the ADVERTISED
|
||||
URL so the client can't reach real EA, but no migration needed. No Rust listener required.
|
||||
- fifa-blaze (FIFA23 capture stub), openfut-bridge (FIFA23): retired lineage, not in FIFA17 flow.
|
||||
|
||||
## Retirement gating
|
||||
1. **Mostly DONE (2026-08-17)**: account/sync, ut/auth (Rust SID mint), userMassInfo (full), clientdata,
|
||||
club/stats/{country,league,team}, watchList, static acks, the flag-off empty reads
|
||||
(season/tournament/champion/clubUser/user/list → `{}`), item-defs (item/resource,defid), and
|
||||
marketdata (+/pricelimits, container-type-exact) migrated + deployed. **Remaining on Python**:
|
||||
user/club rename (mutating; needs a Core write), non-active squad/<n> (no multi-squad Core model),
|
||||
and the unimplemented modes draft/* + leaderboards + /sbs/* (need the mode logic ported, not a proxy).
|
||||
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.
|
||||
4. Only after a long stable window: consider retiring the P2 rollback (separate explicit decision — NOT now).
|
||||
@@ -0,0 +1,66 @@
|
||||
# Live UTAS wire captures — 2026-08-15
|
||||
|
||||
Fresh sanitised FIFA 17 UTAS wire, captured during the post-P1 staging A/B on a
|
||||
**real FIFA 17 client** (`10.10.0.105`) driving the isolated post-P1 candidate
|
||||
backend (`10.10.0.121`, host bin `fda40d12`, Core `fbb54ea`). Production untouched.
|
||||
|
||||
This rebuilds the primary-capture corpus that was lost to `.gitignore` (Known Issues
|
||||
#200 / "take sanitised captures on the next live FIFA run and commit them").
|
||||
|
||||
## Files
|
||||
- `utas-requests.sanitised.txt` — 10 real client requests (`---`-separated).
|
||||
- `utas-responses.sanitised.txt` — 12 responses.
|
||||
|
||||
Captured with `tcpdump` on the container netns (UTAS is plain HTTP on `:8099`),
|
||||
reassembled + split + **sanitised** by `openfut-staging/extract_http.py`. Redacted:
|
||||
`X-UT-SID`/`sid` (`<SID>`), `authCode` (`<AUTH>`), `deviceId` (`<DEV>`), `macAddress`
|
||||
(`<MAC>`), and any 32+char hex token (`<TOKEN>`). The raw `.pcap` is intentionally
|
||||
NOT committed (it is unsanitised).
|
||||
|
||||
## Route contracts captured (verbatim shapes)
|
||||
|
||||
### POST /openfut/account/sync (launcher control-plane, Python-served)
|
||||
Request (launcher → backend):
|
||||
```json
|
||||
{"personaId":33068179,"personaName":"CAGE","level":1,"experience":0,
|
||||
"experienceMax":1000,"accountFunds":0,"accountFundsCap":100000}
|
||||
```
|
||||
Response:
|
||||
```json
|
||||
{"account":{"personaId":33068179,"personaName":"CAGE","clubName":"OpenFUT",
|
||||
"clubAbbr":"OFC","level":1,"experience":0,"experienceMax":1000,"accountFunds":0,
|
||||
"accountFundsCap":100000,"profilePath":"accounts/33068179/fifa17_profile.json",
|
||||
"coins":29826776,"unopenedPacks":0},"status":"OK"}
|
||||
```
|
||||
NOTE: `coins` here is Python's STALE profile value (29,826,776) — Core's authoritative
|
||||
balance at capture time was 29,859,876. The launcher's `AccountSummary` parser is
|
||||
lenient and requires only `{personaId,personaName,level,experience,accountFunds,coins,
|
||||
unopenedPacks}` (clubName/clubAbbr/etc. ignored). BLOCKER for a Rust migration is NOT
|
||||
the shape — it is that Python `account/sync` also *selects the active profile* the
|
||||
still-Python-served userMassInfo envelope depends on, so it is coupled to the
|
||||
userMassInfo hybrid and cannot migrate standalone.
|
||||
|
||||
### GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true (empty My-Packs sentinel)
|
||||
The `mypacks` group carries the synthetic sentinel pack when My Packs is empty:
|
||||
```json
|
||||
{"assetId":65534,"displayGroup":{"priority":1,"value":"mypacks"},"id":65534,
|
||||
"packType":"GOLD","packContentInfo":{"goldQuantity":0,"itemQuantity":0,...},
|
||||
"state":"active","unopened":false}
|
||||
```
|
||||
Client renders empty My Packs AS Browse Packs (no crash) — the bug-6c resolver guard.
|
||||
|
||||
### GET /ut/game/fifa17/userMassInfo (RUST_OVERLAY hybrid, full 8 KB envelope captured)
|
||||
The complete envelope is in `utas-responses.sanitised.txt`: root `pileSizeClientData` +
|
||||
`userInfo` (with `clubName`, `clubAbbr`, `established`, `accountCreatedPlatformName`,
|
||||
`currencies` [Core coins overlaid by Rust], `won/draw/loss`, `clubNameChangeAllowed`,
|
||||
`divisionOffline/Online`, `purchased`, `feature`, `reliability`, `bidTokens`, `trophies`,
|
||||
`sessionCoinsBankBalance`, `actives`, `squadList`). Field spec is authoritative in
|
||||
`fifa17-recon/tools/utas_server.py::user_info()`. This is the target shape for a future
|
||||
full-Rust userMassInfo (needs the `clubAbbr`/`established` account triad, which Core lacks).
|
||||
|
||||
### Other captured request lines
|
||||
`POST /ut/auth`, `GET user/accountinfo`, `GET settings`, `GET phishing/trusteddevice`,
|
||||
`PUT match/reset`, `GET userMassInfo`, `PUT clientdata/userHubData`,
|
||||
`PUT /ut/v2/game/fifa17/store/transaction/0`.
|
||||
|
||||
See `openfut-staging/p1p-live-2026-08-15/AB_VERDICT.md` for the full A/B result.
|
||||
@@ -0,0 +1,117 @@
|
||||
POST /openfut/account/sync HTTP/1.1
|
||||
Host: 10.10.0.121:8099
|
||||
Content-Type: application/json
|
||||
Content-Length: 131
|
||||
Connection: close
|
||||
|
||||
{"personaId":33068179,"personaName":"CAGE","level":1,"experience":0,"experienceMax":1000,"accountFunds":0,"accountFundsCap":100000}
|
||||
|
||||
---
|
||||
|
||||
GET /ut/game/fifa17/user/accountinfo HTTP/1.1
|
||||
Host: 10.10.0.121:8099
|
||||
Connection: Close
|
||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Accept-Encoding: gzip
|
||||
Easw-Session-Data-Nucleus-Id: 33068179
|
||||
Accept-Encoding: gzip
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
POST /ut/auth HTTP/1.1
|
||||
Host: 10.10.0.121:8099
|
||||
Content-Length: 380
|
||||
Connection: Close
|
||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Hash: <TOKEN>
|
||||
|
||||
{"isReadOnly":false,"priorityLevel":6,"sku":"FFA17PCC","nucleusPersonaPlatform":"pc","clientVersion":3,"nuc":33068179,"nucleusPersonaId":33068179,"nucleusPersonaDisplayName":"CAGE","locale":"en-US","regionCode":"US","deviceId":"<DEV>","macAddress":"<MAC>","method":"authcode","identification":{"authCode":"<AUTH>"}}
|
||||
|
||||
---
|
||||
|
||||
GET /ut/game/fifa17/settings HTTP/1.1
|
||||
Host: 10.10.0.121:8099
|
||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
X-UT-SID: <SID>
|
||||
Accept-Encoding: gzip
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
GET /ut/game/fifa17/phishing/trusteddevice?deviceId=<DEV> HTTP/1.1
|
||||
Host: 10.10.0.121:8099
|
||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
X-UT-SID: <SID>
|
||||
Accept-Encoding: gzip
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
PUT /ut/game/fifa17/match/reset HTTP/1.1
|
||||
Host: 10.10.0.121:8099
|
||||
Content-Length: 0
|
||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
X-UT-SID: <SID>
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
GET /ut/game/fifa17/userMassInfo HTTP/1.1
|
||||
Host: 10.10.0.121:8099
|
||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
X-UT-SID: <SID>
|
||||
Accept-Encoding: gzip
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
PUT /ut/v2/game/fifa17/store/transaction/0 HTTP/1.1
|
||||
Host: 10.10.0.121:8099
|
||||
Content-Length: 29
|
||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
X-UT-SID: <SID>
|
||||
|
||||
{"state":"TRANSACTIONCANCEL"}
|
||||
|
||||
---
|
||||
|
||||
PUT /ut/game/fifa17/clientdata/userHubData HTTP/1.1
|
||||
Host: 10.10.0.121:8099
|
||||
Content-Length: 53
|
||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
X-UT-SID: <SID>
|
||||
|
||||
{"entries":[{"key":0,"value":0},{"key":1,"value":1}]}
|
||||
|
||||
---
|
||||
|
||||
GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true HTTP/1.1
|
||||
Host: 10.10.0.121:8099
|
||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
X-UT-SID: <SID>
|
||||
Accept-Encoding: gzip
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
}
|
||||
+1
-1
Submodule fifa-blaze updated: d2a9a01ec9...f4f33969f2
@@ -15,16 +15,27 @@ reimplementations (the `tdf` crate cloned in this scratchpad), which were used
|
||||
only as a cross-check of *structure*, never copied.
|
||||
NO EA/FIFA leaked source was consulted.
|
||||
|
||||
VALIDATED RULES (byte-exact round-trip against the 219-byte capture)
|
||||
--------------------------------------------------------------------
|
||||
Fire2 frame header, 16 bytes big-endian:
|
||||
[0:4] u32 payload length (bytes after the header)
|
||||
[4:6] u16 always 0 (observed)
|
||||
VALIDATED RULES (the TDF body; byte-exact round-trip against the 219-byte capture)
|
||||
---------------------------------------------------------------------------------
|
||||
Fire2 frame header, 16 bytes big-endian.
|
||||
|
||||
!!! SUPERSEDED — the [10:16] FIELD SEMANTICS below are WRONG for FIFA 17. !!!
|
||||
The "byte-exact round-trip" only proves the payload length and the TDF body
|
||||
encoding: decoding then re-encoding with the SAME (mis)labelled header layout
|
||||
trivially reproduces the capture, so it never tested the header's field
|
||||
boundaries. The authoritative, live-driven layout is
|
||||
`openfut-protocol-blaze::fire2` / `blaze_responder_v3b.py::fire2`:
|
||||
[0:4] u32 payload length (bytes after header + metadata)
|
||||
[4:6] u16 metadata length (0 when absent — what this file called "always 0")
|
||||
[6:8] u16 component
|
||||
[8:10] u16 command
|
||||
[10:12]u16 error / msgId
|
||||
[12] u8 msgType (0x01 ping, 0x02 request, 0x03 pong/response)
|
||||
[13:16]3 reserved bytes (observed 00 00 00)
|
||||
[10:13] u24 msgNum (this file WRONGLY split it as [10:12] msgId + [12] msgType)
|
||||
[13] u8 (msgType << 5) | (userIndex & 0x1F)
|
||||
[14] u8 options
|
||||
[15] u8 reserved
|
||||
There is NO error field in Fire2 (that is Fire v1) and NO jumbo escape — the
|
||||
length is already a full u32. `build_fire2_frame`/`decode_fire2` below keep the
|
||||
old wrong `>IHHHHB3s` layout; they are dead and retained only for history.
|
||||
|
||||
Heat2 field = 3-byte packed tag + 1 type byte + value.
|
||||
|
||||
@@ -359,7 +370,14 @@ MSG_ERROR = 0x05 # UNVERIFIED
|
||||
|
||||
def build_fire2_frame(component: int, command: int, msgType: int,
|
||||
msgId: int, tdf_bytes: bytes) -> bytes:
|
||||
"""16-byte big-endian Fire2 header + TDF payload."""
|
||||
"""16-byte big-endian Fire2 header + TDF payload.
|
||||
|
||||
WRONG HEADER (dead code): the ``>IHHHHB3s`` layout mislabels [10:16] — it
|
||||
puts a u16 msgId at [10:12] and msgType at [12]. FIFA 17's real Fire2 header
|
||||
is [10:13] u24 msgNum, [13] (msgType<<5)|userIndex, [14] options, [15]
|
||||
reserved, and has no error field. Use ``openfut-protocol-blaze::fire2`` or
|
||||
``blaze_responder_v3b.py::fire2``; this is retained only for history.
|
||||
"""
|
||||
tdf_bytes = bytes(tdf_bytes)
|
||||
hdr = struct.pack(">IHHHHB3s", len(tdf_bytes), 0, component & 0xFFFF,
|
||||
command & 0xFFFF, msgId & 0xFFFF, msgType & 0xFF,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read the FIFA 17 TRADING gate byte out of the live client. READ-ONLY.
|
||||
|
||||
Extends tools/gate_byte_probe.py with vtable slot +0x270 (IS_TRADING_ENABLED,
|
||||
displacement 0x1fd2e) plus the two pile-size dwords, which the transfer-market
|
||||
analysis names as the market screen's CardsDLL-supplied inputs.
|
||||
|
||||
Opens /proc/<pid>/mem O_RDONLY and preads. Nothing here can write.
|
||||
"""
|
||||
import os, struct
|
||||
|
||||
pid = None
|
||||
for d in os.listdir('/proc'):
|
||||
if d.isdigit():
|
||||
try:
|
||||
if open('/proc/%s/comm' % d).read().strip() == 'FIFA17.exe':
|
||||
pid = int(d)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
assert pid, "FIFA17.exe not running"
|
||||
|
||||
base = None
|
||||
for ln in open('/proc/%d/maps' % pid):
|
||||
if 'CardsDLL' in ln:
|
||||
base = int(ln.split('-')[0], 16)
|
||||
assert base, "CardsDLL not mapped (client has not reached Ultimate Team)"
|
||||
slide = base - 0x180000000
|
||||
|
||||
fd = os.open('/proc/%d/mem' % pid, os.O_RDONLY)
|
||||
|
||||
|
||||
def rd(va, n):
|
||||
return os.pread(fd, n, va)
|
||||
|
||||
|
||||
# Control: the FNV atom-hash prologue must match the on-disk PE before any other
|
||||
# address is trusted.
|
||||
pe = open('/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll', 'rb').read()
|
||||
|
||||
|
||||
def f(va):
|
||||
return va - 0x180000000 - 0x1000 + 0x400
|
||||
|
||||
|
||||
ok = pe[f(0x180180d00):f(0x180180d00) + 32] == rd(0x180180d00 + slide, 32)
|
||||
print("pid=%d slide=%#x FNV control=%s" % (pid, slide, "MATCH" if ok else "MISMATCH"))
|
||||
assert ok, "slide not proven; refusing to read further"
|
||||
|
||||
obj = struct.unpack('<Q', rd(0x1802e6398 + slide, 8))[0]
|
||||
vt = struct.unpack('<Q', rd(obj, 8))[0]
|
||||
print("model=%#x vtable(static)=%#x" % (obj, vt - slide))
|
||||
|
||||
SLOTS = [
|
||||
(0x270, 'IS_TRADING_ENABLED '),
|
||||
(0x2b0, 'IS_FRIENDLY_SEASON '),
|
||||
(0x2c8, 'IS_DRAFT_MODE '),
|
||||
(0x2e0, 'packOpeningAnimation '),
|
||||
]
|
||||
print("\n-- gate bytes decoded from their accessor stubs --")
|
||||
for off, name in SLOTS:
|
||||
slot = struct.unpack('<Q', rd(vt + off, 8))[0]
|
||||
stub = rd(slot, 8)
|
||||
if stub[:3] == b'\x0f\xb6\x81':
|
||||
disp = struct.unpack('<I', stub[3:7])[0]
|
||||
val = rd(obj + disp, 1)[0]
|
||||
print(" slot +%#05x %s disp=%#x VALUE=%d" % (off, name, disp, val))
|
||||
else:
|
||||
print(" slot +%#05x %s NOT a movzx stub: %s" % (off, name, stub.hex()))
|
||||
|
||||
print("\n-- market screen inputs --")
|
||||
for disp, name in [(0x1fd1c, 'TRADE_PILE_SIZE'), (0x1fd20, 'watchListSize '),
|
||||
(0x1fd2e, 'tradingEnabled '), (0x1fd2f, 'storeEnabled ')]:
|
||||
print(" model+%#x %s = %d" % (disp, name, rd(obj + disp, 1)[0]))
|
||||
|
||||
os.close(fd)
|
||||
@@ -20,6 +20,8 @@ use std::collections::HashMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::fut::content_taxonomy::ContentKind;
|
||||
|
||||
/// The FIFA 17 render identity of a card definition. `version` is the high byte
|
||||
/// of `resource_id`; `asset_id` (the low 24 bits) is the real FIFA player id.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -30,6 +32,12 @@ pub struct Fifa17CardIdentity {
|
||||
/// FIFA wire `rareflag` (rare/special card TYPE). Carried so specials render
|
||||
/// as specials; observed metadata, not a guessed label.
|
||||
pub rareflag: i64,
|
||||
/// Content class of this definition. A catalog authored before this field
|
||||
/// existed defaults to [`ContentKind::Player`] (backward compatible).
|
||||
pub kind: ContentKind,
|
||||
/// FIFA `cardsubtypeid` for a non-player definition (consumable family /
|
||||
/// staff role), `0` for a player or when absent.
|
||||
pub subtype: i64,
|
||||
}
|
||||
|
||||
/// The FIFA 17 numeric namespace policy for owned-item wire ids.
|
||||
@@ -115,6 +123,14 @@ struct RawCard {
|
||||
/// behaviour; the production catalog carries the observed value.
|
||||
#[serde(default = "default_rareflag")]
|
||||
rareflag: i64,
|
||||
/// Content class token ("player"|"consumable"|"staff"). Absent → default
|
||||
/// (empty) → [`ContentKind::Player`], so existing player-only catalogs load
|
||||
/// unchanged.
|
||||
#[serde(default)]
|
||||
kind: String,
|
||||
/// FIFA `cardsubtypeid` for a non-player entry; absent → `0`.
|
||||
#[serde(default)]
|
||||
subtype: i64,
|
||||
}
|
||||
|
||||
fn default_rareflag() -> i64 {
|
||||
@@ -169,6 +185,8 @@ impl Fifa17CardCatalog {
|
||||
version: rc.version,
|
||||
resource_id,
|
||||
rareflag: rc.rareflag,
|
||||
kind: ContentKind::from_str(&rc.kind),
|
||||
subtype: rc.subtype,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -197,6 +215,21 @@ impl Fifa17CardCatalog {
|
||||
self.by_resource.get(&resource_id).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Classify a `card_id` as player/consumable/staff. An unknown definition is
|
||||
/// [`ContentKind::Player`] — the neutral, backward-compatible default (an
|
||||
/// un-catalogued id was always treated as a player-shaped card).
|
||||
pub fn kind_of(&self, card_id: &str) -> ContentKind {
|
||||
self.by_card
|
||||
.get(card_id)
|
||||
.map(|c| c.kind)
|
||||
.unwrap_or(ContentKind::Player)
|
||||
}
|
||||
|
||||
/// The FIFA `cardsubtypeid` for a definition, or `0` if unknown / a player.
|
||||
pub fn subtype_of(&self, card_id: &str) -> i64 {
|
||||
self.by_card.get(card_id).map(|c| c.subtype).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.by_card.len()
|
||||
}
|
||||
@@ -335,4 +368,44 @@ mod tests {
|
||||
assert_eq!(ron.version, 0);
|
||||
assert_eq!(ron.resource_id, 20801);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_catalog_without_kind_loads_as_player() {
|
||||
// A pre-taxonomy catalog (no `kind`/`subtype`) must load unchanged and
|
||||
// classify every entry as a player, with subtype 0.
|
||||
let cat = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||
"fifa17_20801":{"asset_id":20801},
|
||||
"fifa17_176580":{"asset_id":176580,"version":5,"rareflag":3}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let base = cat.lookup("fifa17_20801").unwrap();
|
||||
assert_eq!(base.kind, ContentKind::Player);
|
||||
assert_eq!(base.subtype, 0);
|
||||
assert_eq!(base.rareflag, 1, "absent rareflag still defaults to 1");
|
||||
assert_eq!(cat.kind_of("fifa17_20801"), ContentKind::Player);
|
||||
assert_eq!(cat.kind_of("fifa17_176580"), ContentKind::Player);
|
||||
// Unknown id -> neutral Player default.
|
||||
assert_eq!(cat.kind_of("fifa17_missing"), ContentKind::Player);
|
||||
assert_eq!(cat.subtype_of("fifa17_missing"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_and_subtype_are_parsed_for_non_player_entries() {
|
||||
let cat = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||
"fifa17_20801":{"asset_id":20801,"kind":"player","subtype":0},
|
||||
"fifa17_5003012":{"asset_id":5003012,"kind":"consumable","subtype":54,"rareflag":0},
|
||||
"fifa17_3000083":{"asset_id":3000083,"kind":"staff","subtype":8,"rareflag":0}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cat.kind_of("fifa17_20801"), ContentKind::Player);
|
||||
assert_eq!(cat.kind_of("fifa17_5003012"), ContentKind::Consumable);
|
||||
assert_eq!(cat.subtype_of("fifa17_5003012"), 54);
|
||||
assert_eq!(cat.kind_of("fifa17_3000083"), ContentKind::Staff);
|
||||
assert_eq!(cat.subtype_of("fifa17_3000083"), 8);
|
||||
assert_eq!(cat.lookup("fifa17_5003012").unwrap().rareflag, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::content_taxonomy::ContentKind;
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
use crate::fut::item::shape_item;
|
||||
// Re-exported so existing `club_response::{…}` callers keep working; the types
|
||||
@@ -25,6 +26,12 @@ pub fn shape_club_response<I: ItemIdentityResolver + ?Sized>(
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
let mut stats = ShapeStats::default();
|
||||
for item in items {
|
||||
// Exclude non-player content (consumables/staff): a `/club` player list
|
||||
// must never render them as 0-rated players. Counted, never emitted.
|
||||
if ident.kind_of(item) != ContentKind::Player {
|
||||
stats.excluded_non_player += 1;
|
||||
continue;
|
||||
}
|
||||
match ident.resolve(item) {
|
||||
Some(id) => {
|
||||
out.push(shape_item(item, id, ent));
|
||||
@@ -193,4 +200,66 @@ mod tests {
|
||||
"only itemData at top level"
|
||||
);
|
||||
}
|
||||
|
||||
/// A resolver that resolves an asset id for EVERY item (so exclusion is not
|
||||
/// an artifact of a missing asset) but classifies some card_ids as non-player
|
||||
/// via an explicit kind table.
|
||||
struct KindMapIdentity {
|
||||
ids: HashMap<String, Fifa17Identity>,
|
||||
kinds: HashMap<String, ContentKind>,
|
||||
}
|
||||
impl ItemIdentityResolver for KindMapIdentity {
|
||||
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||
self.ids.get(&it.card_id).copied()
|
||||
}
|
||||
fn kind_of(&self, it: &CoreOwnedItem) -> ContentKind {
|
||||
self.kinds
|
||||
.get(&it.card_id)
|
||||
.copied()
|
||||
.unwrap_or(ContentKind::Player)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_and_staff_are_excluded_from_club_players() {
|
||||
let ent = entities();
|
||||
let id = |item_id: u32, asset: u32| Fifa17Identity {
|
||||
item_id,
|
||||
asset_id: asset,
|
||||
resource_id: asset,
|
||||
rareflag: 1,
|
||||
};
|
||||
let ident = KindMapIdentity {
|
||||
ids: HashMap::from([
|
||||
("card_player".to_string(), id(100000001, 20801)),
|
||||
("card_consumable".to_string(), id(100000002, 5003012)),
|
||||
("card_staff".to_string(), id(100000003, 3000083)),
|
||||
]),
|
||||
kinds: HashMap::from([
|
||||
("card_consumable".to_string(), ContentKind::Consumable),
|
||||
("card_staff".to_string(), ContentKind::Staff),
|
||||
]),
|
||||
};
|
||||
let items = vec![
|
||||
item(
|
||||
"oc1",
|
||||
"card_player",
|
||||
86,
|
||||
"ST",
|
||||
"Argentina",
|
||||
"Premier League",
|
||||
"Chelsea",
|
||||
),
|
||||
item("oc2", "card_consumable", 0, "", "", "", ""),
|
||||
item("oc3", "card_staff", 0, "", "", "", ""),
|
||||
];
|
||||
let (body, stats) = shape_club_response(&items, &ent, &ident);
|
||||
assert_eq!(stats.emitted, 1, "only the player is emitted");
|
||||
assert_eq!(stats.excluded_non_player, 2, "consumable + staff excluded");
|
||||
assert_eq!(stats.dropped_no_asset, 0);
|
||||
let arr = body["itemData"].as_array().unwrap();
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(arr[0]["id"], 100000001, "the player survives");
|
||||
assert_eq!(arr[0]["itemType"], "player");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
//! FIFA17 MY CLUB stat set (`GET …/club/stats/{year,consumables}`), computed
|
||||
//! from OpenFUT Core's authoritative owned inventory.
|
||||
//!
|
||||
//! Faithful port of the Python oracle's `fut_club_stats.py` (`global_counts` +
|
||||
//! `context_rows` + `stats_body`), which is itself censused from CardsDLL
|
||||
//! (`FUN_18012fd40` atom table). The body is `{"stat":[{contextId,contextValue,
|
||||
//! type,typeValue}, …]}`:
|
||||
//! * a GLOBAL bucket (contextId 1, contextValue 0) with player tier counts,
|
||||
//! staff-by-family, consumables-by-family, and honest zeros for club items;
|
||||
//! * per-NATION buckets (contextId 3, contextValue = nation id) with the tier
|
||||
//! counts the MY CLUB summary panel sums into PLAYERS_EMPLOYED.
|
||||
//!
|
||||
//! Unlike the oracle (which counts its own stale profile + a synthetic consumable
|
||||
//! shelf), this counts Core — so staff and consumable families reflect the real
|
||||
//! imported content. Unrecognized atoms are inert in the client, so this is a
|
||||
//! low-risk cosmetic surface; the tier/staff/consumable atoms are the ones the
|
||||
//! screen reads and they are Core-accurate here.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
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`/`league_id`/
|
||||
/// `team_id` from the reverse entity resolver (None = unresolved, bucket skipped).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClubStatInput {
|
||||
pub kind: ContentKind,
|
||||
pub subtype: i64,
|
||||
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).
|
||||
const S_PLAYERS: i64 = 0x01;
|
||||
const S_BRONZE: i64 = 0x02;
|
||||
const S_SILVER: i64 = 0x03;
|
||||
const S_GOLD: i64 = 0x04;
|
||||
const S_RARE: i64 = 0x05;
|
||||
const S_STAFF: i64 = 0x0A;
|
||||
const S_CONSUMABLES: i64 = 0x3C;
|
||||
const S_KITS: i64 = 0x28;
|
||||
const S_BADGES: i64 = 0x2D;
|
||||
|
||||
/// cardsubtypeid (staff family) -> stat id (STAFF_SUBTYPE_STAT).
|
||||
fn staff_stat(subtype: i64) -> Option<i64> {
|
||||
match subtype {
|
||||
4 => Some(0x0B), // manager
|
||||
5 => Some(0x0C), // head coach
|
||||
6 => Some(0x0D), // GK coach
|
||||
7 => Some(0x0E), // physio
|
||||
8 => Some(0x0F), // fitness coach
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// consumable family `kind` -> stat id (CONSUMABLE_KIND_STAT).
|
||||
fn consumable_stat(kind: &str) -> Option<i64> {
|
||||
Some(match kind {
|
||||
"player_contract" => 0x42,
|
||||
"manager_contract" => 0x47,
|
||||
"healing" => 0x41,
|
||||
"player_fitness" => 0x44,
|
||||
"squad_fitness" => 0x4A,
|
||||
"gk_training" => 0x46,
|
||||
"player_training" => 0x43,
|
||||
"position_mod" => 0x45,
|
||||
"player_playstyle" => 0x4B,
|
||||
"gk_playstyle" => 0x4C,
|
||||
"manager_league" => 0x4D,
|
||||
"manager_formation_mod" | "formation_mod" => 0x48,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The JSON `type` atom name for a stat id (VOCAB). Only the ids this module
|
||||
/// emits are mapped; an unmapped id would panic (guards a transcription slip).
|
||||
fn vocab(stat_id: i64) -> &'static str {
|
||||
match stat_id {
|
||||
0x01 => "players",
|
||||
0x02 => "playersBronze",
|
||||
0x03 => "playersSilver",
|
||||
0x04 => "playersGold",
|
||||
0x05 => "rarePlayers",
|
||||
0x0A => "staff",
|
||||
0x0B => "staffManager",
|
||||
0x0C => "staffHeadCoach",
|
||||
0x0D => "staffGKCoach",
|
||||
0x0E => "staffPhysio",
|
||||
0x0F => "staffFitnessCoach",
|
||||
0x14 => "stadia",
|
||||
0x1E => "balls",
|
||||
0x28 => "kits",
|
||||
0x29 => "kitsHome",
|
||||
0x2A => "kitsAway",
|
||||
0x2D => "badges",
|
||||
0x2E => "badgeDBid",
|
||||
0x2F => "leagueLogos",
|
||||
0x32 => "trophies",
|
||||
0x33 => "trophiesOffline",
|
||||
0x34 => "trophiesOnline",
|
||||
0x35 => "trophiesFeaturedOffline",
|
||||
0x36 => "trophiesFeaturedOnline",
|
||||
0x37 => "trophiesSeasonOffline",
|
||||
0x38 => "trophiesSeasonOnline",
|
||||
0x3C => "consumables",
|
||||
0x41 => "consumablesHealing",
|
||||
0x42 => "consumablesContractPlayer",
|
||||
0x43 => "consumablesTrainingPlayer",
|
||||
0x44 => "consumablesFitnessPlayer",
|
||||
0x45 => "consumablesPosition",
|
||||
0x46 => "consumablesTrainingGk",
|
||||
0x47 => "consumablesContractManager",
|
||||
0x48 => "consumablesFormationManager",
|
||||
0x49 => "consumablesTrainingManager",
|
||||
0x4A => "consumablesFitnessTeam",
|
||||
0x4B => "consumablesTrainingPlayerPlayStyle",
|
||||
0x4C => "consumablesTrainingGkPlayStyle",
|
||||
0x4D => "consumablesTrainingManagerLeagueModifier",
|
||||
other => panic!("club_stats: unmapped stat id {other:#x}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn row(context_id: i64, context_value: i64, stat_id: i64, value: i64) -> Value {
|
||||
json!({
|
||||
"contextId": context_id,
|
||||
"contextValue": context_value,
|
||||
"type": vocab(stat_id),
|
||||
"typeValue": value,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_player(i: &ClubStatInput) -> bool {
|
||||
matches!(i.kind, ContentKind::Player)
|
||||
}
|
||||
|
||||
/// 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();
|
||||
g.insert(S_PLAYERS, players.len() as i64);
|
||||
g.insert(
|
||||
S_GOLD,
|
||||
players.iter().filter(|i| i.rating >= 75).count() as i64,
|
||||
);
|
||||
g.insert(
|
||||
S_SILVER,
|
||||
players
|
||||
.iter()
|
||||
.filter(|i| (65..75).contains(&i.rating))
|
||||
.count() as i64,
|
||||
);
|
||||
g.insert(
|
||||
S_BRONZE,
|
||||
players
|
||||
.iter()
|
||||
.filter(|i| i.rating > 0 && i.rating < 65)
|
||||
.count() as i64,
|
||||
);
|
||||
g.insert(S_RARE, players.iter().filter(|i| i.rare).count() as i64);
|
||||
|
||||
// staff per family + total
|
||||
for sid in [0x0B, 0x0C, 0x0D, 0x0E, 0x0F] {
|
||||
g.insert(sid, 0);
|
||||
}
|
||||
let mut staff_total = 0i64;
|
||||
for it in items
|
||||
.iter()
|
||||
.filter(|i| matches!(i.kind, ContentKind::Staff))
|
||||
{
|
||||
if let Some(sid) = staff_stat(it.subtype) {
|
||||
*g.get_mut(&sid).unwrap() += 1;
|
||||
staff_total += 1;
|
||||
}
|
||||
}
|
||||
g.insert(S_STAFF, staff_total);
|
||||
|
||||
// consumables per family + total
|
||||
for sid in [
|
||||
0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D,
|
||||
] {
|
||||
g.insert(sid, 0);
|
||||
}
|
||||
let mut cons_total = 0i64;
|
||||
for it in items
|
||||
.iter()
|
||||
.filter(|i| matches!(i.kind, ContentKind::Consumable))
|
||||
{
|
||||
cons_total += 1;
|
||||
if let Some((kind, _label)) = consumable_family(it.subtype) {
|
||||
if let Some(sid) = consumable_stat(kind) {
|
||||
*g.entry(sid).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
g.insert(S_CONSUMABLES, cons_total);
|
||||
|
||||
// club items: honest zeros (Core holds none; each is read by some panel).
|
||||
for sid in [
|
||||
0x14, 0x1E, 0x28, 0x29, 0x2A, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
|
||||
] {
|
||||
g.entry(sid).or_insert(0);
|
||||
}
|
||||
|
||||
let mut stat: Vec<Value> = g.iter().map(|(sid, v)| row(1, 0, *sid, *v)).collect();
|
||||
|
||||
// ---- 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 {
|
||||
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 (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 })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn player(rating: i64, rare: bool, nation: Option<i64>) -> ClubStatInput {
|
||||
ClubStatInput {
|
||||
kind: ContentKind::Player,
|
||||
subtype: 0,
|
||||
rating,
|
||||
rare,
|
||||
nation_id: nation,
|
||||
league_id: None,
|
||||
team_id: None,
|
||||
}
|
||||
}
|
||||
fn staff(subtype: i64) -> ClubStatInput {
|
||||
ClubStatInput {
|
||||
kind: ContentKind::Staff,
|
||||
subtype,
|
||||
rating: 0,
|
||||
rare: false,
|
||||
nation_id: None,
|
||||
league_id: None,
|
||||
team_id: None,
|
||||
}
|
||||
}
|
||||
fn consumable(subtype: i64) -> ClubStatInput {
|
||||
ClubStatInput {
|
||||
kind: ContentKind::Consumable,
|
||||
subtype,
|
||||
rating: 0,
|
||||
rare: false,
|
||||
nation_id: None,
|
||||
league_id: None,
|
||||
team_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn global(body: &Value) -> std::collections::HashMap<String, i64> {
|
||||
body["stat"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|r| r["contextId"] == 1)
|
||||
.map(|r| {
|
||||
(
|
||||
r["type"].as_str().unwrap().to_string(),
|
||||
r["typeValue"].as_i64().unwrap(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiers_and_rare_counted() {
|
||||
let items = vec![
|
||||
player(90, true, Some(52)),
|
||||
player(70, true, Some(52)),
|
||||
player(60, false, Some(21)),
|
||||
];
|
||||
let g = global(&club_stats_body(&items, ContextField::Nation));
|
||||
assert_eq!(g["players"], 3);
|
||||
assert_eq!(g["playersGold"], 1);
|
||||
assert_eq!(g["playersSilver"], 1);
|
||||
assert_eq!(g["playersBronze"], 1);
|
||||
assert_eq!(g["rarePlayers"], 2);
|
||||
}
|
||||
|
||||
#[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, ContextField::Nation));
|
||||
assert_eq!(g["staffGKCoach"], 1);
|
||||
assert_eq!(g["staffFitnessCoach"], 2);
|
||||
assert_eq!(g["staff"], 3);
|
||||
assert_eq!(g["staffManager"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumables_by_family() {
|
||||
// 54 gk_training, 201 player_contract, 217 healing, 258 player_playstyle
|
||||
let items = vec![
|
||||
consumable(54),
|
||||
consumable(201),
|
||||
consumable(217),
|
||||
consumable(258),
|
||||
];
|
||||
let g = global(&club_stats_body(&items, ContextField::Nation));
|
||||
assert_eq!(g["consumables"], 4);
|
||||
assert_eq!(g["consumablesTrainingGk"], 1);
|
||||
assert_eq!(g["consumablesContractPlayer"], 1);
|
||||
assert_eq!(g["consumablesHealing"], 1);
|
||||
assert_eq!(g["consumablesTrainingPlayerPlayStyle"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nation_buckets_emitted_and_players_excludes_nonplayers() {
|
||||
let items = vec![
|
||||
player(90, true, Some(52)),
|
||||
player(80, false, Some(52)),
|
||||
staff(8),
|
||||
];
|
||||
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"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|r| r["contextId"] == 3 && r["contextValue"] == 52)
|
||||
.collect();
|
||||
// gold, silver, bronze, rare, kits, badges
|
||||
assert_eq!(buckets.len(), 6);
|
||||
let gold = buckets.iter().find(|r| r["type"] == "playersGold").unwrap();
|
||||
assert_eq!(gold["typeValue"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn honest_zero_club_items_present() {
|
||||
let g = global(&club_stats_body(&[player(90, false, None)], ContextField::Nation));
|
||||
for atom in [
|
||||
"stadia",
|
||||
"balls",
|
||||
"kits",
|
||||
"badges",
|
||||
"trophies",
|
||||
"leagueLogos",
|
||||
] {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! FIFA 17 **non-player content taxonomy** — the evidence-based map from a card
|
||||
//! `cardsubtypeid` to its functional family (consumables) or role (staff).
|
||||
//!
|
||||
//! This is the ONLY place the FIFA-specific `cardsubtypeid` vocabulary lives; it
|
||||
//! keeps that game concept out of generic Core, exactly as the player-side
|
||||
//! catalog keeps `resourceId`/`rareflag` out of Core. Nothing here is guessed:
|
||||
//!
|
||||
//! * Consumable families and their contiguous `cardsubtypeid` ranges are taken
|
||||
//! verbatim from `fifa17-recon/tools/fut_consumables.py`
|
||||
//! (`BY_SUBTYPE`/`CORE_KINDS`, Ghidra-derived from `FUN_18013f4d0` /
|
||||
//! `FUN_1801bfac0`) and `docs/CARD_TAXONOMY.md` (verified against the `.105`
|
||||
//! `fcc_*.json` tables).
|
||||
//! * Staff roles are the `FUN_1800d8330` family selector: 4=manager, 5=headcoach,
|
||||
//! 6=gkcoach, 7=physio, 8=fitnesscoach.
|
||||
//!
|
||||
//! Display **labels are functional, never marketing** (e.g. "Player Chemistry
|
||||
//! Style", not a promo name). A `cardsubtypeid` outside every documented range
|
||||
//! resolves to `None` — the caller DEFERS it (mirroring the player NoName gate),
|
||||
//! never fabricating a family.
|
||||
|
||||
/// The disjoint content classes a FIFA 17 owned card can belong to. Player is
|
||||
/// the default so a catalog authored before this taxonomy existed (no `kind`
|
||||
/// field) still classifies every entry as a player, unchanged.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ContentKind {
|
||||
#[default]
|
||||
Player,
|
||||
Consumable,
|
||||
Staff,
|
||||
}
|
||||
|
||||
impl ContentKind {
|
||||
/// The stable wire/catalog token for this kind.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ContentKind::Player => "player",
|
||||
ContentKind::Consumable => "consumable",
|
||||
ContentKind::Staff => "staff",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a catalog `kind` token. Unknown or "player" (or an absent field that
|
||||
/// deserializes to the default) is `Player` — backward compatible.
|
||||
// Intentionally infallible (every input maps to a kind, unknown → Player), so
|
||||
// it is NOT `std::str::FromStr` (which is fallible); the name mirrors the
|
||||
// catalog token vocabulary.
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
pub fn from_str(s: &str) -> ContentKind {
|
||||
match s {
|
||||
"consumable" => ContentKind::Consumable,
|
||||
"staff" => ContentKind::Staff,
|
||||
_ => ContentKind::Player,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The functional family + honest display label for a consumable `cardsubtypeid`,
|
||||
/// or `None` if the subtype is outside every documented range (→ DEFER).
|
||||
///
|
||||
/// Returns `(family, label)`, both `'static`. `family` is the neutral machine
|
||||
/// name stored as the CardDefinition family; `label` is the functional
|
||||
/// human-readable name.
|
||||
pub fn consumable_family(subtype: i64) -> Option<(&'static str, &'static str)> {
|
||||
let pair = match subtype {
|
||||
51..=57 => ("gk_training", "GK Training"),
|
||||
61..=67 => ("player_training", "Player Training"),
|
||||
71..=86 => ("manager_formation_mod", "Manager Formation"),
|
||||
91..=110 => ("position_mod", "Position Modifier"),
|
||||
121..=136 => ("formation_mod", "Formation Modifier"),
|
||||
201 => ("player_contract", "Player Contract"),
|
||||
202 => ("manager_contract", "Manager Contract"),
|
||||
211..=218 => ("healing", "Healing"),
|
||||
219 => ("player_fitness", "Player Fitness"),
|
||||
220 => ("squad_fitness", "Squad Fitness"),
|
||||
250..=268 => ("player_playstyle", "Player Chemistry Style"),
|
||||
269..=273 => ("gk_playstyle", "GK Chemistry Style"),
|
||||
300..=341 => ("manager_league", "Manager League Modifier"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(pair)
|
||||
}
|
||||
|
||||
/// The staff role + honest display label for a staff `cardsubtypeid` (4..=8), or
|
||||
/// `None` for any other subtype (→ DEFER). Grounded in the `FUN_1800d8330`
|
||||
/// family selector.
|
||||
pub fn staff_role(subtype: i64) -> Option<(&'static str, &'static str)> {
|
||||
let pair = match subtype {
|
||||
4 => ("manager", "Manager"),
|
||||
5 => ("headcoach", "Head Coach"),
|
||||
6 => ("gkcoach", "GK Coach"),
|
||||
7 => ("physio", "Physio"),
|
||||
8 => ("fitnesscoach", "Fitness Coach"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(pair)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn content_kind_round_trips_and_defaults_to_player() {
|
||||
assert_eq!(ContentKind::default(), ContentKind::Player);
|
||||
for k in [
|
||||
ContentKind::Player,
|
||||
ContentKind::Consumable,
|
||||
ContentKind::Staff,
|
||||
] {
|
||||
assert_eq!(ContentKind::from_str(k.as_str()), k);
|
||||
}
|
||||
// Unknown / absent tokens fall back to Player (backward compatible).
|
||||
assert_eq!(ContentKind::from_str(""), ContentKind::Player);
|
||||
assert_eq!(ContentKind::from_str("nonsense"), ContentKind::Player);
|
||||
assert_eq!(ContentKind::from_str("player"), ContentKind::Player);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_family_range_boundaries() {
|
||||
// Each contiguous range: lower boundary, upper boundary, family + label.
|
||||
let cases: &[(i64, i64, &str, &str)] = &[
|
||||
(51, 57, "gk_training", "GK Training"),
|
||||
(61, 67, "player_training", "Player Training"),
|
||||
(71, 86, "manager_formation_mod", "Manager Formation"),
|
||||
(91, 110, "position_mod", "Position Modifier"),
|
||||
(121, 136, "formation_mod", "Formation Modifier"),
|
||||
(211, 218, "healing", "Healing"),
|
||||
(250, 268, "player_playstyle", "Player Chemistry Style"),
|
||||
(269, 273, "gk_playstyle", "GK Chemistry Style"),
|
||||
(300, 341, "manager_league", "Manager League Modifier"),
|
||||
];
|
||||
for &(lo, hi, family, label) in cases {
|
||||
assert_eq!(consumable_family(lo), Some((family, label)), "lo {lo}");
|
||||
assert_eq!(consumable_family(hi), Some((family, label)), "hi {hi}");
|
||||
}
|
||||
// Singleton subtypes.
|
||||
assert_eq!(
|
||||
consumable_family(201),
|
||||
Some(("player_contract", "Player Contract"))
|
||||
);
|
||||
assert_eq!(
|
||||
consumable_family(202),
|
||||
Some(("manager_contract", "Manager Contract"))
|
||||
);
|
||||
assert_eq!(
|
||||
consumable_family(219),
|
||||
Some(("player_fitness", "Player Fitness"))
|
||||
);
|
||||
assert_eq!(
|
||||
consumable_family(220),
|
||||
Some(("squad_fitness", "Squad Fitness"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_family_gaps_and_out_of_range_are_none() {
|
||||
// Just outside range edges, and in documented gaps between ranges.
|
||||
for s in [
|
||||
0, 50, 58, 60, 68, 70, 87, 90, 111, 120, 137, 200, 203, 210, 221, 249, 274, 299, 342,
|
||||
999,
|
||||
] {
|
||||
assert_eq!(consumable_family(s), None, "subtype {s} must be unknown");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staff_role_each_role_and_unknown_is_none() {
|
||||
assert_eq!(staff_role(4), Some(("manager", "Manager")));
|
||||
assert_eq!(staff_role(5), Some(("headcoach", "Head Coach")));
|
||||
assert_eq!(staff_role(6), Some(("gkcoach", "GK Coach")));
|
||||
assert_eq!(staff_role(7), Some(("physio", "Physio")));
|
||||
assert_eq!(staff_role(8), Some(("fitnesscoach", "Fitness Coach")));
|
||||
for s in [0, 1, 2, 3, 9, 10, 201, 300] {
|
||||
assert_eq!(staff_role(s), None, "staff subtype {s} must be unknown");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::content_taxonomy::ContentKind;
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
|
||||
/// One owned item in game-independent terms, as read from Core's inventory.
|
||||
@@ -69,6 +70,15 @@ pub struct Fifa17Identity {
|
||||
/// "no real FIFA asset id known" → the caller must not fabricate one.
|
||||
pub trait ItemIdentityResolver {
|
||||
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity>;
|
||||
|
||||
/// Classify a Core item's definition as player/consumable/staff. Defaults to
|
||||
/// [`ContentKind::Player`] so existing resolvers keep their behaviour; a
|
||||
/// catalog-backed resolver overrides this to consult its `kind_of`, letting
|
||||
/// `/club` exclude non-player content (which must never render as a
|
||||
/// 0-rated player).
|
||||
fn kind_of(&self, _item: &CoreOwnedItem) -> ContentKind {
|
||||
ContentKind::Player
|
||||
}
|
||||
}
|
||||
|
||||
/// Diagnostics from shaping (safe to log — counts only).
|
||||
@@ -76,6 +86,9 @@ pub trait ItemIdentityResolver {
|
||||
pub struct ShapeStats {
|
||||
pub emitted: usize,
|
||||
pub dropped_no_asset: usize,
|
||||
/// Consumable/staff items excluded from a player projection (they must never
|
||||
/// render as a 0-rated player). Counted, never emitted.
|
||||
pub excluded_non_player: usize,
|
||||
}
|
||||
|
||||
/// Quick-sell / discard value by rating tier (mirrors Core's quick-sell table;
|
||||
@@ -133,7 +146,12 @@ pub fn shape_item(
|
||||
"attributeList": attribute_list,
|
||||
"itemState": "free",
|
||||
"owners": 1,
|
||||
"untradeable": true,
|
||||
// Owned/pack-pulled cards are TRADEABLE in FIFA 17 (untradeable is the
|
||||
// exception for SBC/promo rewards, which Core does not model). Emitting
|
||||
// `true` greyed out "Place on Transfer Market" for every card — the same
|
||||
// "our own data showing through" bug the Python oracle fixed by forcing
|
||||
// this off for owned copies (item_def keeps `true`; instances do not).
|
||||
"untradeable": false,
|
||||
"contract": 7,
|
||||
"fitness": 99,
|
||||
"discardValue": discard_value(item.rating),
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
//! socket — a Rust UTAS host wires it to Core later.
|
||||
pub mod catalog;
|
||||
pub mod club_response;
|
||||
pub mod club_stats;
|
||||
pub mod content_taxonomy;
|
||||
pub mod economy;
|
||||
pub mod economy_policy;
|
||||
pub mod entities;
|
||||
pub mod item;
|
||||
pub mod non_economy;
|
||||
pub mod owned_query;
|
||||
pub mod pack_content;
|
||||
pub mod squad;
|
||||
|
||||
@@ -0,0 +1,675 @@
|
||||
//! FIFA17 non-economy presentation routes, migrated from the Python oracle.
|
||||
//!
|
||||
//! Pure, IO-free shapers that reproduce the **observed production** oracle
|
||||
//! contract (`fifa17-recon/tools/utas_server.py`) for the non-economy UTAS
|
||||
//! routes a Rust host can own without any Core or account state:
|
||||
//!
|
||||
//! * `GET …/user/accountinfo` → `{}` (FUT_ACCOUNTINFO off)
|
||||
//! * `GET …/settings` → `{"configs":[]}` (FUT_SETTINGS off)
|
||||
//! * `GET …/leaderboards/options` → `{}` (FUT_MODES off)
|
||||
//! * `PUT …/match/reset` → `{}` (Tier-B no-op ack)
|
||||
//! * `GET/POST/PUT …/phishing/{trusteddevice,question,validate}` — the retired
|
||||
//! FUT security-question service, a stateless acknowledgement.
|
||||
//!
|
||||
//! These match the bodies the live prod oracle actually returns under the
|
||||
//! production environment (`FUT_TRADING=1 FUT_PILESIZES=1 FUT_TRADEABLE=1`, none
|
||||
//! of the feature flags set). They carry no persisted state: the security
|
||||
//! record the oracle seeds (`{version:1,verified:true}`) is log-only — the
|
||||
//! response is invariant — so a faithful Rust owner needs no persistence.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// `GET …/user/accountinfo` — production oracle returns an empty object.
|
||||
pub fn accountinfo_body() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
/// `GET …/settings` — production oracle returns an empty config list.
|
||||
pub fn settings_body() -> Value {
|
||||
json!({ "configs": [] })
|
||||
}
|
||||
|
||||
/// `GET …/leaderboards/options` — production oracle (FUT_MODES off) returns an
|
||||
/// empty object; the retail client ignores the body.
|
||||
pub fn leaderboard_options_body() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
/// `PUT …/match/reset` — Tier-B no-op acknowledgement.
|
||||
pub fn match_reset_body() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
/// `GET …/club/stats/staff` — production oracle returns an empty object (FIFA's
|
||||
/// staff-bonus stat set is deliberately empty; the client tolerates `{}`).
|
||||
pub fn club_stats_staff_body() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
/// The FUT modes (Seasons / Tournaments / FUT Champions) and the club-identity
|
||||
/// service are disabled in this emulator, so their read routes (`season`,
|
||||
/// `tournament`, `champion`, `clubUser`, `user/list`) return an empty object —
|
||||
/// byte-identical to the Python oracle with `FUT_MODES` / `FUT_CLUB_IDENTITY`
|
||||
/// off. Enabling a mode later requires a real Rust implementation here, never a
|
||||
/// Python fallback (which would reintroduce split authority).
|
||||
pub fn feature_off_body() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
/// One FUT item-definition for a requested `resource_id`, replicating the Python
|
||||
/// oracle's `item_def`: `assetId = resource_id & 0xffffff`; a single hardcoded
|
||||
/// card (Ronaldo, asset 20801) and a generic placeholder (`"Player"`, 75, CM,
|
||||
/// attrs 70) for every other asset. The FIFA client renders the real card from
|
||||
/// its LOCAL DB from the `(rareflag, resourceId)` pair, so this route only needs
|
||||
/// a valid-shaped record — the placeholder is exactly what the oracle itself
|
||||
/// returns for all but the one hardcoded asset. Key order is irrelevant (the
|
||||
/// client's deserializer is key-addressed and skip-safe).
|
||||
pub fn item_def(resource_id: i64) -> Value {
|
||||
let asset = resource_id & 0xff_ffff;
|
||||
// (name, rating, position, nation, leagueId, teamid, [6 attrs])
|
||||
let (name, rating, pos, nation, league, team, attrs): (&str, i64, &str, i64, i64, i64, [i64; 6]) =
|
||||
if asset == 20801 {
|
||||
("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80])
|
||||
} else {
|
||||
("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70])
|
||||
};
|
||||
let attribute_list: Vec<Value> = attrs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| json!({ "index": i, "value": v }))
|
||||
.collect();
|
||||
json!({
|
||||
"id": resource_id,
|
||||
"resourceId": resource_id,
|
||||
"definitionId": resource_id,
|
||||
"assetId": asset,
|
||||
"cardassetid": asset,
|
||||
"commodityId": asset,
|
||||
"cardsubtypeid": 0,
|
||||
"cardType": 0,
|
||||
"itemType": "player",
|
||||
"rareflag": 1,
|
||||
"rating": rating,
|
||||
"preferredPosition": pos,
|
||||
"nation": nation,
|
||||
"leagueId": league,
|
||||
"teamid": team,
|
||||
"playStyle": 250,
|
||||
"attributeList": attribute_list,
|
||||
"name": name,
|
||||
"commonName": name,
|
||||
"lastName": name,
|
||||
"itemState": "free",
|
||||
"untradeable": true,
|
||||
})
|
||||
}
|
||||
|
||||
/// `GET …/item/resource`, `…/defid` — `{itemData:[…]}` with one [`item_def`] per
|
||||
/// requested id (mirrors the oracle's `defs_route`). No ids → an empty list.
|
||||
pub fn item_defs_body(ids: &[i64]) -> Value {
|
||||
json!({ "itemData": ids.iter().map(|&id| item_def(id)).collect::<Vec<_>>() })
|
||||
}
|
||||
|
||||
/// `GET …/marketdata/pricelimits?defId=a,b,c` — FutGetSuggestedPricing. The root
|
||||
/// MUST be a BARE ARRAY (one element per defId): returning an object here froze a
|
||||
/// live client (object-where-array busy loop at the listing screen). Constant
|
||||
/// band 150..15000 (placeholder pricing; not a freeze concern).
|
||||
pub fn marketdata_pricelimits_body(def_ids: &[i64]) -> Value {
|
||||
json!(def_ids
|
||||
.iter()
|
||||
.map(|&d| json!({ "defId": d, "minPrice": 150, "maxPrice": 15000 }))
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// `GET …/marketdata` (NOT `/pricelimits`) — the price-comparison endpoint, which
|
||||
/// takes an OBJECT `{minPrice,maxPrice}`. Array-where-object would be the same
|
||||
/// freeze in reverse, so the container type is load-bearing. Constant band.
|
||||
pub fn marketdata_object_body() -> Value {
|
||||
json!({ "minPrice": 150, "maxPrice": 15000 })
|
||||
}
|
||||
|
||||
/// The phishing/security-question action, parsed from the URL tail.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SecurityAction {
|
||||
TrustedDevice,
|
||||
Question,
|
||||
Validate,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// The `phishing/<action>` selector from a `…/phishing/<action>` path tail.
|
||||
pub fn parse_security_action(tail: &str) -> SecurityAction {
|
||||
let last = tail.trim_end_matches('/').rsplit('/').next().unwrap_or("");
|
||||
match last {
|
||||
"trusteddevice" => SecurityAction::TrustedDevice,
|
||||
"question" => SecurityAction::Question,
|
||||
"validate" => SecurityAction::Validate,
|
||||
_ => SecurityAction::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// A well-formed FUT phishing token is an opaque 32-char lowercase/uppercase hex
|
||||
/// value (`_PHISHING_HEX32.fullmatch` in the oracle).
|
||||
fn is_hex32(s: &str) -> bool {
|
||||
s.len() == 32 && s.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Reproduce `utas_server.py::security_question_route` verbatim.
|
||||
///
|
||||
/// The retired FUT security-question service is a stateless acknowledgement: it
|
||||
/// validates request shape (session, 32-hex device id, 32-hex answer, numeric
|
||||
/// question) and returns fixed bodies. The answer is a client-transformed opaque
|
||||
/// value that is **never stored or compared**; the trusted-device response is an
|
||||
/// invariant `verified/trusted` constant.
|
||||
///
|
||||
/// * `session_known` — whether `X-UT-SID` maps to an open Rust session.
|
||||
/// * `device_id` / `question` / `answer` — decoded query parameters (`""`/`None`
|
||||
/// when absent).
|
||||
///
|
||||
/// Returns `(http_status, body)`.
|
||||
pub fn security_question_response(
|
||||
method: &str,
|
||||
action: SecurityAction,
|
||||
session_known: bool,
|
||||
device_id: &str,
|
||||
question: Option<&str>,
|
||||
answer: Option<&str>,
|
||||
) -> (u16, Value) {
|
||||
let is = |m: &str| method.eq_ignore_ascii_case(m);
|
||||
if !session_known {
|
||||
return (400, json!({ "reason": "invalid_session" }));
|
||||
}
|
||||
if !is_hex32(device_id) {
|
||||
return (400, json!({ "reason": "malformed_request" }));
|
||||
}
|
||||
match action {
|
||||
SecurityAction::TrustedDevice => {
|
||||
if !is("GET") {
|
||||
return (405, json!({ "reason": "method_not_allowed" }));
|
||||
}
|
||||
(
|
||||
200,
|
||||
json!({ "changed": false, "exists": true, "locked": false, "trusted": true }),
|
||||
)
|
||||
}
|
||||
SecurityAction::Question if is("GET") => (
|
||||
200,
|
||||
json!({ "question": 0, "attempts": 5, "recoverAttempts": 0 }),
|
||||
),
|
||||
SecurityAction::Question if is("POST") || is("PUT") => {
|
||||
let q = question.unwrap_or("");
|
||||
let a = answer.unwrap_or("");
|
||||
if q.is_empty() || !q.bytes().all(|b| b.is_ascii_digit()) || !is_hex32(a) {
|
||||
return (400, json!({ "reason": "malformed_request" }));
|
||||
}
|
||||
(200, json!({}))
|
||||
}
|
||||
SecurityAction::Validate if is("POST") => {
|
||||
let a = answer.unwrap_or("");
|
||||
if !is_hex32(a) {
|
||||
return (400, json!({ "reason": "malformed_request" }));
|
||||
}
|
||||
(200, json!({}))
|
||||
}
|
||||
_ => (405, json!({ "reason": "method_not_allowed" })),
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────── 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,
|
||||
}
|
||||
|
||||
/// The FIFA persona display name for this emulator's single account.
|
||||
///
|
||||
/// The oracle sources this from the shared account (`fut_account.py`, default
|
||||
/// `"CAGE"`) and documents the property as "Blaze PDTL.DSNM / LSX
|
||||
/// GetProfileResponse Persona / **UTAS sellerName**". That last role is
|
||||
/// load-bearing: the client decides whether a transfer-market listing is the
|
||||
/// player's OWN — and therefore whether to offer Remove / Re-list at all — from
|
||||
/// the seller identity on the auction record. Stamping EA's house name there
|
||||
/// makes the player's own listing un-actionable (pressing it opens no dialog).
|
||||
pub const PERSONA_DISPLAY_NAME: &str = "CAGE";
|
||||
|
||||
/// 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(PERSONA_DISPLAY_NAME)
|
||||
.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::*;
|
||||
|
||||
const DEV: &str = "6236375476659cd0f6c780e728774b71"; // 32-hex (live deviceId)
|
||||
const ANS: &str = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
#[test]
|
||||
fn static_bodies_match_oracle() {
|
||||
assert_eq!(accountinfo_body(), json!({}));
|
||||
assert_eq!(settings_body(), json!({ "configs": [] }));
|
||||
assert_eq!(leaderboard_options_body(), json!({}));
|
||||
assert_eq!(match_reset_body(), json!({}));
|
||||
assert_eq!(club_stats_staff_body(), json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_parse() {
|
||||
assert_eq!(
|
||||
parse_security_action("phishing/trusteddevice"),
|
||||
SecurityAction::TrustedDevice
|
||||
);
|
||||
assert_eq!(
|
||||
parse_security_action("phishing/question/"),
|
||||
SecurityAction::Question
|
||||
);
|
||||
assert_eq!(
|
||||
parse_security_action("phishing/validate"),
|
||||
SecurityAction::Validate
|
||||
);
|
||||
assert_eq!(
|
||||
parse_security_action("phishing/other"),
|
||||
SecurityAction::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_device_verified_constant() {
|
||||
let (s, b) =
|
||||
security_question_response("GET", SecurityAction::TrustedDevice, true, DEV, None, None);
|
||||
assert_eq!(s, 200);
|
||||
assert_eq!(
|
||||
b,
|
||||
json!({ "changed": false, "exists": true, "locked": false, "trusted": true })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_session_is_400_invalid_session() {
|
||||
let (s, b) = security_question_response(
|
||||
"GET",
|
||||
SecurityAction::TrustedDevice,
|
||||
false,
|
||||
DEV,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(s, 400);
|
||||
assert_eq!(b, json!({ "reason": "invalid_session" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_device_id_is_malformed() {
|
||||
for bad in [
|
||||
"",
|
||||
"xyz",
|
||||
"6236375476659cd0f6c780e728774b7",
|
||||
"not-hex-not-hex-not-hex-not-hexx",
|
||||
] {
|
||||
let (s, b) = security_question_response(
|
||||
"GET",
|
||||
SecurityAction::TrustedDevice,
|
||||
true,
|
||||
bad,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(s, 400, "device {bad:?}");
|
||||
assert_eq!(b, json!({ "reason": "malformed_request" }));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_device_wrong_method_405() {
|
||||
let (s, _) = security_question_response(
|
||||
"POST",
|
||||
SecurityAction::TrustedDevice,
|
||||
true,
|
||||
DEV,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(s, 405);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_get_returns_prompt() {
|
||||
let (s, b) =
|
||||
security_question_response("GET", SecurityAction::Question, true, DEV, None, None);
|
||||
assert_eq!(s, 200);
|
||||
assert_eq!(
|
||||
b,
|
||||
json!({ "question": 0, "attempts": 5, "recoverAttempts": 0 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_setup_validates_shape() {
|
||||
// valid numeric question + 32-hex answer
|
||||
let (s, b) = security_question_response(
|
||||
"POST",
|
||||
SecurityAction::Question,
|
||||
true,
|
||||
DEV,
|
||||
Some("0"),
|
||||
Some(ANS),
|
||||
);
|
||||
assert_eq!(s, 200);
|
||||
assert_eq!(b, json!({}));
|
||||
// empty question -> malformed
|
||||
let (s, _) = security_question_response(
|
||||
"POST",
|
||||
SecurityAction::Question,
|
||||
true,
|
||||
DEV,
|
||||
Some(""),
|
||||
Some(ANS),
|
||||
);
|
||||
assert_eq!(s, 400);
|
||||
// non-digit question -> malformed
|
||||
let (s, _) = security_question_response(
|
||||
"PUT",
|
||||
SecurityAction::Question,
|
||||
true,
|
||||
DEV,
|
||||
Some("x"),
|
||||
Some(ANS),
|
||||
);
|
||||
assert_eq!(s, 400);
|
||||
// bad answer -> malformed
|
||||
let (s, _) = security_question_response(
|
||||
"POST",
|
||||
SecurityAction::Question,
|
||||
true,
|
||||
DEV,
|
||||
Some("0"),
|
||||
Some("short"),
|
||||
);
|
||||
assert_eq!(s, 400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_checks_answer() {
|
||||
let (s, b) = security_question_response(
|
||||
"POST",
|
||||
SecurityAction::Validate,
|
||||
true,
|
||||
DEV,
|
||||
None,
|
||||
Some(ANS),
|
||||
);
|
||||
assert_eq!(s, 200);
|
||||
assert_eq!(b, json!({}));
|
||||
let (s, _) = security_question_response(
|
||||
"POST",
|
||||
SecurityAction::Validate,
|
||||
true,
|
||||
DEV,
|
||||
None,
|
||||
Some("nope"),
|
||||
);
|
||||
assert_eq!(s, 400);
|
||||
// wrong method for validate
|
||||
let (s, _) =
|
||||
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");
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule openfut-core updated: fbb54eac95...68d10658c7
@@ -181,6 +181,23 @@ pub fn plan_apply(
|
||||
});
|
||||
}
|
||||
}
|
||||
// Non-player (consumable/staff) owned instances mint via the IDENTICAL
|
||||
// generic path: deterministic OwnedItemId per (persona, wire), an identity
|
||||
// mapping, and a GenericOwned with card_id = fifa17_<resourceId>.
|
||||
for def in &report.non_player.supported {
|
||||
for &wire in &def.wire_ids {
|
||||
let core_id = owned_item_id(persona, wire);
|
||||
wire_to_owned.insert(wire, core_id.clone());
|
||||
owned.push(GenericOwned {
|
||||
owned_item_id: core_id.clone(),
|
||||
card_id: def.card_id.clone(),
|
||||
});
|
||||
mappings.push(IdentityMapping {
|
||||
core_id,
|
||||
wire_id: wire,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical squad + opaque extension, built by the SAME adapter code the live
|
||||
// squad-write path uses, over the raw source squad. The resolver maps every
|
||||
@@ -253,8 +270,14 @@ pub fn plan_apply(
|
||||
request,
|
||||
mappings,
|
||||
watermark: report.identity.source_watermark,
|
||||
supported_instances: report.identity.import_wire_ids.len(),
|
||||
deferred_instances: report.deferred_instances(),
|
||||
supported_instances: report.identity.import_wire_ids.len()
|
||||
+ report
|
||||
.non_player
|
||||
.supported
|
||||
.iter()
|
||||
.map(|d| d.wire_ids.len())
|
||||
.sum::<usize>(),
|
||||
deferred_instances: report.deferred_instances() + report.non_player.deferred_instances(),
|
||||
source_fingerprint: snapshot_fingerprint.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ pub mod apply;
|
||||
pub mod model;
|
||||
|
||||
use model::{Item, Profile};
|
||||
use openfut_adapter_fifa17::fut::content_taxonomy::{consumable_family, staff_role, ContentKind};
|
||||
|
||||
// ----------------------------------------------------------------- roster
|
||||
|
||||
@@ -521,6 +522,157 @@ pub fn plan_definitions(
|
||||
plan
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- non-player content
|
||||
|
||||
/// An honest, profile-derived NON-player CardDefinition proposal (consumable or
|
||||
/// staff), keyed by `fifa17_<resourceId>`. Neutral player fields are supplied at
|
||||
/// emit time; this carries only the identity + honest functional `name` (the
|
||||
/// taxonomy label, never a marketing name).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NonPlayerDefinition {
|
||||
pub card_id: String,
|
||||
pub resource_id: i64,
|
||||
/// Base asset id when the source carries one (consumables: `== resource_id`);
|
||||
/// staff carry no `assetId`, so this is `None`.
|
||||
pub asset_id: Option<i64>,
|
||||
pub kind: ContentKind,
|
||||
/// FIFA `cardsubtypeid` (consumable family / staff role selector).
|
||||
pub subtype: i64,
|
||||
/// Honest functional label (e.g. "Player Contract", "GK Coach").
|
||||
pub name: String,
|
||||
/// Wire ids of every owned copy of this resourceId (preserved).
|
||||
pub wire_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
/// A non-player group that cannot be honestly classified (DEFERRED, never
|
||||
/// fabricated). Mirrors the player NoName gate.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeferredNonPlayer {
|
||||
pub resource_id: i64,
|
||||
/// The agreed subtype when present; `None` when absent or in conflict.
|
||||
pub subtype: Option<i64>,
|
||||
pub wire_ids: Vec<i64>,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct NonPlayerPlan {
|
||||
pub supported: Vec<NonPlayerDefinition>,
|
||||
pub deferred: Vec<DeferredNonPlayer>,
|
||||
/// Count of SUPPORTED consumable definitions.
|
||||
pub consumables: usize,
|
||||
/// Count of SUPPORTED staff definitions.
|
||||
pub staff: usize,
|
||||
}
|
||||
|
||||
impl NonPlayerPlan {
|
||||
/// Deferred non-player INSTANCES (owned copies) across all deferred groups.
|
||||
pub fn deferred_instances(&self) -> usize {
|
||||
self.deferred.iter().map(|d| d.wire_ids.len()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan the non-player (consumable + staff) CardDefinitions. Groups Consumable
|
||||
/// and Staff items by `resourceId`; each group must agree on `cardsubtypeid`
|
||||
/// across copies (a disagreement DEFERS with `subtype_conflict`), then resolves
|
||||
/// the family (consumable) or role (staff) via the adapter's evidence-based
|
||||
/// taxonomy. A missing or unknown `cardsubtypeid` DEFERS — never a placeholder.
|
||||
pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
|
||||
let mut groups: BTreeMap<i64, Vec<&Item>> = BTreeMap::new();
|
||||
for it in &profile.items {
|
||||
if matches!(classify(it), ItemClass::Consumable | ItemClass::Staff) {
|
||||
groups.entry(it.resource_id).or_default().push(it);
|
||||
}
|
||||
}
|
||||
|
||||
let mut plan = NonPlayerPlan::default();
|
||||
for (resource_id, items) in groups {
|
||||
let wire_ids: Vec<i64> = items.iter().map(|i| i.id).collect();
|
||||
|
||||
// Class agreement (a resourceId is either all-consumable or all-staff).
|
||||
let class = classify(items[0]);
|
||||
if items.iter().any(|i| classify(i) != class) {
|
||||
plan.deferred.push(DeferredNonPlayer {
|
||||
resource_id,
|
||||
subtype: None,
|
||||
wire_ids,
|
||||
reason: "class_conflict".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Subtype must agree across every owned copy (identity invariant).
|
||||
let first_subtype = items[0].cardsubtypeid;
|
||||
if items.iter().any(|i| i.cardsubtypeid != first_subtype) {
|
||||
plan.deferred.push(DeferredNonPlayer {
|
||||
resource_id,
|
||||
subtype: None,
|
||||
wire_ids,
|
||||
reason: "subtype_conflict".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let Some(subtype) = first_subtype else {
|
||||
plan.deferred.push(DeferredNonPlayer {
|
||||
resource_id,
|
||||
subtype: None,
|
||||
wire_ids,
|
||||
reason: "missing_cardsubtypeid".to_string(),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
|
||||
let (kind, label) = match class {
|
||||
ItemClass::Consumable => match consumable_family(subtype) {
|
||||
Some((_family, label)) => (ContentKind::Consumable, label),
|
||||
None => {
|
||||
plan.deferred.push(DeferredNonPlayer {
|
||||
resource_id,
|
||||
subtype: Some(subtype),
|
||||
wire_ids,
|
||||
reason: "unknown_subtype".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
},
|
||||
ItemClass::Staff => match staff_role(subtype) {
|
||||
Some((_role, label)) => (ContentKind::Staff, label),
|
||||
None => {
|
||||
plan.deferred.push(DeferredNonPlayer {
|
||||
resource_id,
|
||||
subtype: Some(subtype),
|
||||
wire_ids,
|
||||
reason: "unknown_subtype".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
},
|
||||
_ => unreachable!("only Consumable/Staff were grouped"),
|
||||
};
|
||||
|
||||
plan.supported.push(NonPlayerDefinition {
|
||||
card_id: format!("fifa17_{resource_id}"),
|
||||
resource_id,
|
||||
asset_id: items[0].asset_id,
|
||||
kind,
|
||||
subtype,
|
||||
name: label.to_string(),
|
||||
wire_ids,
|
||||
});
|
||||
}
|
||||
plan.consumables = plan
|
||||
.supported
|
||||
.iter()
|
||||
.filter(|d| d.kind == ContentKind::Consumable)
|
||||
.count();
|
||||
plan.staff = plan
|
||||
.supported
|
||||
.iter()
|
||||
.filter(|d| d.kind == ContentKind::Staff)
|
||||
.count();
|
||||
plan
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- identity
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -632,6 +784,8 @@ pub struct Report {
|
||||
pub definitions: DefinitionPlan,
|
||||
pub identity: IdentityPlan,
|
||||
pub squad: SquadCoverage,
|
||||
/// Consumable + staff content (supported definitions + deferred groups).
|
||||
pub non_player: NonPlayerPlan,
|
||||
/// Unconsumed pack entitlements to seed (from `unopenedPackIds`).
|
||||
pub unopened_pack_ids: Vec<i64>,
|
||||
}
|
||||
@@ -720,6 +874,7 @@ pub fn analyze(
|
||||
let identity = plan_identity(profile, &supported_rids);
|
||||
let supported_wire: BTreeSet<i64> = identity.import_wire_ids.iter().copied().collect();
|
||||
let squad = plan_squad(profile, &supported_wire);
|
||||
let non_player = plan_non_player_definitions(profile);
|
||||
Report {
|
||||
game: "fifa17".to_string(),
|
||||
persona_id: profile.persona_id,
|
||||
@@ -731,6 +886,7 @@ pub fn analyze(
|
||||
definitions,
|
||||
identity,
|
||||
squad,
|
||||
non_player,
|
||||
unopened_pack_ids: profile.unopened_pack_ids.clone(),
|
||||
}
|
||||
}
|
||||
@@ -740,6 +896,7 @@ impl std::fmt::Display for Report {
|
||||
let d = &self.definitions;
|
||||
let id = &self.identity;
|
||||
let sq = &self.squad;
|
||||
let np = &self.non_player;
|
||||
writeln!(f, "OpenFUT FIFA17 real-profile import — analysis")?;
|
||||
writeln!(f, "=============================================")?;
|
||||
writeln!(
|
||||
@@ -819,11 +976,30 @@ impl std::fmt::Display for Report {
|
||||
}
|
||||
writeln!(
|
||||
f,
|
||||
"\nRESULT would_import_players={} deferred_player_instances={} deferred_consumables={} deferred_staff={}",
|
||||
"\nNON-PLAYER CONTENT (consumable/staff) supported={} (consumables={} staff={}) deferred_groups={} deferred_instances={}",
|
||||
np.supported.len(),
|
||||
np.consumables,
|
||||
np.staff,
|
||||
np.deferred.len(),
|
||||
np.deferred_instances()
|
||||
)?;
|
||||
for nd in &np.deferred {
|
||||
writeln!(
|
||||
f,
|
||||
" DEFER resourceId={} subtype={:?} copies={} reason={}",
|
||||
nd.resource_id,
|
||||
nd.subtype,
|
||||
nd.wire_ids.len(),
|
||||
nd.reason
|
||||
)?;
|
||||
}
|
||||
writeln!(
|
||||
f,
|
||||
"\nRESULT would_import_players={} would_import_non_players={} deferred_player_instances={} deferred_non_player_instances={}",
|
||||
id.import_wire_ids.len(),
|
||||
np.supported.iter().map(|d| d.wire_ids.len()).sum::<usize>(),
|
||||
self.deferred_instances(),
|
||||
self.counts.consumables,
|
||||
self.counts.staff
|
||||
np.deferred_instances()
|
||||
)?;
|
||||
let blockers = self.blockers();
|
||||
if blockers.is_empty() {
|
||||
@@ -861,6 +1037,10 @@ pub struct EmitSummary {
|
||||
pub catalog_entries: usize,
|
||||
pub supported_instances: usize,
|
||||
pub deferred_instances: usize,
|
||||
/// Non-player (consumable/staff) supported definitions written.
|
||||
pub non_player_definitions: usize,
|
||||
/// Non-player supported owned INSTANCES (owned copies across those defs).
|
||||
pub non_player_instances: usize,
|
||||
}
|
||||
|
||||
/// Emit the PUBLIC content pack + host catalog for supported definitions, and a
|
||||
@@ -880,7 +1060,7 @@ pub fn emit_content(
|
||||
std::fs::create_dir_all(&manifest_dir)?;
|
||||
|
||||
// ---- PUBLIC: Core CardDefinition[] (matches openfut-core models::card) ----
|
||||
let defs: Vec<serde_json::Value> = report
|
||||
let mut defs: Vec<serde_json::Value> = report
|
||||
.definitions
|
||||
.supported
|
||||
.iter()
|
||||
@@ -904,15 +1084,58 @@ pub fn emit_content(
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// Non-player CardDefinitions use NEUTRAL player fields + the honest family/
|
||||
// role name; Core stores them like any other definition (no FIFA concept).
|
||||
for d in &report.non_player.supported {
|
||||
defs.push(serde_json::json!({
|
||||
"id": d.card_id,
|
||||
"name": d.name,
|
||||
"overall": 0,
|
||||
"position": "",
|
||||
"nation": "",
|
||||
"league": "",
|
||||
"club": "",
|
||||
"pace": 0,
|
||||
"shooting": 0,
|
||||
"passing": 0,
|
||||
"dribbling": 0,
|
||||
"defending": 0,
|
||||
"physical": 0,
|
||||
"rarity": "bronze",
|
||||
"image_path": serde_json::Value::Null,
|
||||
}));
|
||||
}
|
||||
let content_pack = content_dir.join("fifa17-production-cards.json");
|
||||
write_json_pretty(&content_pack, &defs)?;
|
||||
|
||||
// ---- PUBLIC: host identity catalog {card_id: {asset_id, version, rareflag}} ----
|
||||
let mut cards = serde_json::Map::new();
|
||||
for d in &report.definitions.supported {
|
||||
// Players carry an explicit kind:"player" + subtype:0 so the adapter can
|
||||
// classify EVERY catalogued card (not just non-players).
|
||||
cards.insert(
|
||||
d.card_id.clone(),
|
||||
serde_json::json!({ "asset_id": d.asset_id, "version": d.version, "rareflag": d.rareflag }),
|
||||
serde_json::json!({
|
||||
"asset_id": d.asset_id,
|
||||
"version": d.version,
|
||||
"rareflag": d.rareflag,
|
||||
"kind": "player",
|
||||
"subtype": 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
for d in &report.non_player.supported {
|
||||
// asset_id falls back to resource_id (staff carry no assetId); version 0,
|
||||
// rareflag 0 — a consumable/staff never renders as a special card.
|
||||
cards.insert(
|
||||
d.card_id.clone(),
|
||||
serde_json::json!({
|
||||
"asset_id": d.asset_id.unwrap_or(d.resource_id),
|
||||
"version": 0,
|
||||
"rareflag": 0,
|
||||
"kind": d.kind.as_str(),
|
||||
"subtype": d.subtype,
|
||||
}),
|
||||
);
|
||||
}
|
||||
let catalog = serde_json::json!({
|
||||
@@ -966,8 +1189,44 @@ pub fn emit_content(
|
||||
"distinct_variants": c.distinct.len(),
|
||||
}));
|
||||
}
|
||||
let non_player_supported: Vec<serde_json::Value> = report
|
||||
.non_player
|
||||
.supported
|
||||
.iter()
|
||||
.map(|d| {
|
||||
serde_json::json!({
|
||||
"card_id": d.card_id,
|
||||
"resource_id": d.resource_id,
|
||||
"asset_id": d.asset_id,
|
||||
"kind": d.kind.as_str(),
|
||||
"subtype": d.subtype,
|
||||
"name": d.name,
|
||||
"wire_ids": d.wire_ids,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let non_player_deferred: Vec<serde_json::Value> = report
|
||||
.non_player
|
||||
.deferred
|
||||
.iter()
|
||||
.map(|dd| {
|
||||
serde_json::json!({
|
||||
"resource_id": dd.resource_id,
|
||||
"subtype": dd.subtype,
|
||||
"wire_ids": dd.wire_ids,
|
||||
"reason": dd.reason,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let supported_instances = report.identity.import_wire_ids.len();
|
||||
let deferred_instances = report.deferred_instances();
|
||||
let non_player_definitions = report.non_player.supported.len();
|
||||
let non_player_instances: usize = report
|
||||
.non_player
|
||||
.supported
|
||||
.iter()
|
||||
.map(|d| d.wire_ids.len())
|
||||
.sum();
|
||||
let manifest = serde_json::json!({
|
||||
"generator": "openfut-import-fifa17",
|
||||
"source_kind": "python-profile-observation",
|
||||
@@ -987,6 +1246,12 @@ pub fn emit_content(
|
||||
},
|
||||
"supported_definitions": supported,
|
||||
"deferred": deferred,
|
||||
"non_player": {
|
||||
"supported_definitions": non_player_supported,
|
||||
"supported_instances": non_player_instances,
|
||||
"deferred": non_player_deferred,
|
||||
"deferred_instances": report.non_player.deferred_instances(),
|
||||
},
|
||||
});
|
||||
let manifest_path = manifest_dir.join("fifa17-import-manifest.json");
|
||||
write_json_pretty(&manifest_path, &manifest)?;
|
||||
@@ -996,9 +1261,11 @@ pub fn emit_content(
|
||||
host_catalog,
|
||||
manifest: manifest_path,
|
||||
definitions: report.definitions.supported.len(),
|
||||
catalog_entries: report.definitions.supported.len(),
|
||||
catalog_entries: report.definitions.supported.len() + non_player_definitions,
|
||||
supported_instances,
|
||||
deferred_instances,
|
||||
non_player_definitions,
|
||||
non_player_instances,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,10 @@ fn run() -> Result<ExitCode> {
|
||||
sum.supported_instances,
|
||||
sum.deferred_instances
|
||||
);
|
||||
println!(
|
||||
" non-player : {} definition(s), {} instance(s) (consumable/staff)",
|
||||
sum.non_player_definitions, sum.non_player_instances
|
||||
);
|
||||
}
|
||||
|
||||
if do_apply {
|
||||
|
||||
@@ -63,6 +63,19 @@ pub struct Item {
|
||||
pub league_id: Option<i64>,
|
||||
#[serde(rename = "attributeList", default)]
|
||||
pub attribute_list: Option<Vec<Attr>>,
|
||||
/// FIFA `cardsubtypeid` — the consumable family / staff role selector. Absent
|
||||
/// for player cards; present for consumables and staff.
|
||||
#[serde(default)]
|
||||
pub cardsubtypeid: Option<i64>,
|
||||
/// Consumable ART id (small id), distinct from `resourceId`. Permissive.
|
||||
#[serde(default)]
|
||||
pub cardassetid: Option<i64>,
|
||||
/// Consumable stack size (`amount`). Permissive.
|
||||
#[serde(default)]
|
||||
pub amount: Option<i64>,
|
||||
/// Staff/contract `contract` count. Permissive.
|
||||
#[serde(default)]
|
||||
pub contract: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
||||
@@ -626,3 +626,259 @@ fn apply_fails_gracefully_when_core_binary_missing() {
|
||||
let err = apply_import(&plan, &paths, false).unwrap_err();
|
||||
assert!(format!("{err:#}").contains("spawn core import"), "{err:#}");
|
||||
}
|
||||
|
||||
// -------------------------------------------------- non-player content
|
||||
|
||||
use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog;
|
||||
use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind;
|
||||
|
||||
/// A consumable owned item: itemType="player" but NO attributeList; identity is
|
||||
/// carried entirely by resourceId (== assetId == carddbid) + cardsubtypeid.
|
||||
fn consumable(id: i64, resource: i64, subtype: i64) -> String {
|
||||
format!(
|
||||
r#"{{"id":{id},"resourceId":{resource},"assetId":{resource},"itemType":"player",
|
||||
"cardsubtypeid":{subtype},"cardassetid":3,"amount":1,"rating":0,"rareflag":0}}"#
|
||||
)
|
||||
}
|
||||
|
||||
/// A staff owned item: itemType="staff", resourceId only (NO assetId), keyed by
|
||||
/// cardsubtypeid.
|
||||
fn staff(id: i64, resource: i64, subtype: i64) -> String {
|
||||
format!(
|
||||
r#"{{"id":{id},"resourceId":{resource},"itemType":"staff","cardsubtypeid":{subtype},"contract":10}}"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_non_player_supports_seventeen_consumables_and_three_staff() {
|
||||
// The exact record set from the ticket: distinct resourceIds, so each is its
|
||||
// own definition even where two copies share a subtype (54,54 / 100,100 /
|
||||
// 202,202 / staff 8,8) — subtype duplication across DISTINCT definitions is
|
||||
// not a conflict.
|
||||
let consumable_subtypes = [
|
||||
54, 54, 52, 91, 92, 97, 98, 100, 100, 258, 267, 271, 201, 202, 202, 217, 213,
|
||||
];
|
||||
let staff_subtypes = [8i64, 8, 6];
|
||||
let mut items = Vec::new();
|
||||
for (i, &st) in consumable_subtypes.iter().enumerate() {
|
||||
let i = i as i64;
|
||||
items.push(consumable(100_000_200 + i, 5_003_001 + i, st));
|
||||
}
|
||||
for (i, &st) in staff_subtypes.iter().enumerate() {
|
||||
let i = i as i64;
|
||||
items.push(staff(100_000_300 + i, 3_000_001 + i, st));
|
||||
}
|
||||
let plan = plan_non_player_definitions(&profile(&items, "[]", 100000500));
|
||||
assert_eq!(
|
||||
plan.supported.len(),
|
||||
20,
|
||||
"17 consumable + 3 staff definitions"
|
||||
);
|
||||
assert_eq!(plan.consumables, 17);
|
||||
assert_eq!(plan.staff, 3);
|
||||
assert!(plan.deferred.is_empty(), "0 deferred: {:?}", plan.deferred);
|
||||
|
||||
// Honest labels + kinds resolve from the taxonomy (spot checks).
|
||||
let by_id = |cid: &str| plan.supported.iter().find(|d| d.card_id == cid).unwrap();
|
||||
// subtype 201 -> Player Contract (13th consumable, resource 5003013)
|
||||
let contract = by_id("fifa17_5003013");
|
||||
assert_eq!(contract.name, "Player Contract");
|
||||
assert_eq!(contract.kind, ContentKind::Consumable);
|
||||
assert_eq!(contract.subtype, 201);
|
||||
// subtype 258 -> Player Chemistry Style (10th consumable, resource 5003010)
|
||||
assert_eq!(by_id("fifa17_5003010").name, "Player Chemistry Style");
|
||||
// staff subtype 8 -> Fitness Coach; subtype 6 -> GK Coach
|
||||
let fitness = by_id("fifa17_3000001");
|
||||
assert_eq!(fitness.name, "Fitness Coach");
|
||||
assert_eq!(fitness.kind, ContentKind::Staff);
|
||||
assert_eq!(fitness.asset_id, None, "staff carry no assetId");
|
||||
assert_eq!(by_id("fifa17_3000003").name, "GK Coach");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_subtype_consumable_defers_never_fabricated() {
|
||||
let plan = plan_non_player_definitions(&profile(
|
||||
&[consumable(100000300, 5009999, 999)],
|
||||
"[]",
|
||||
100000500,
|
||||
));
|
||||
assert!(plan.supported.is_empty());
|
||||
assert_eq!(plan.deferred.len(), 1);
|
||||
assert_eq!(plan.deferred[0].reason, "unknown_subtype");
|
||||
assert_eq!(plan.deferred[0].subtype, Some(999));
|
||||
assert_eq!(plan.deferred[0].wire_ids, vec![100000300]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_cardsubtypeid_defers() {
|
||||
// itemType player, no attributeList, no cardsubtypeid -> consumable w/o a
|
||||
// resolvable family -> DEFER (never a placeholder).
|
||||
let item =
|
||||
r#"{"id":100000301,"resourceId":5003050,"assetId":5003050,"itemType":"player","rating":0}"#
|
||||
.to_string();
|
||||
let plan = plan_non_player_definitions(&profile(&[item], "[]", 100000500));
|
||||
assert!(plan.supported.is_empty());
|
||||
assert_eq!(plan.deferred.len(), 1);
|
||||
assert_eq!(plan.deferred[0].reason, "missing_cardsubtypeid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicting_subtype_across_copies_defers() {
|
||||
// Two copies of one resourceId that disagree on subtype -> defer, never a
|
||||
// silent winner.
|
||||
let plan = plan_non_player_definitions(&profile(
|
||||
&[
|
||||
consumable(100000302, 5003060, 201),
|
||||
consumable(100000303, 5003060, 202),
|
||||
],
|
||||
"[]",
|
||||
100000500,
|
||||
));
|
||||
assert!(plan.supported.is_empty());
|
||||
assert_eq!(plan.deferred.len(), 1);
|
||||
assert_eq!(plan.deferred[0].reason, "subtype_conflict");
|
||||
assert_eq!(plan.deferred[0].wire_ids, vec![100000302, 100000303]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_player_deferral_is_not_a_blocker() {
|
||||
// A non-player deferral (like a player NoName deferral) must NOT block emit.
|
||||
let items = vec![
|
||||
player(100000001, 20801, 20801, 94),
|
||||
consumable(100000300, 5009999, 999), // unknown subtype -> deferred
|
||||
];
|
||||
let rep = analyze(
|
||||
&profile(&items, "[]", 100000500),
|
||||
&roster(),
|
||||
&entities(),
|
||||
&none(),
|
||||
);
|
||||
assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers());
|
||||
assert_eq!(rep.non_player.deferred.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
|
||||
let items = vec![
|
||||
player(100000001, 20801, 20801, 94),
|
||||
consumable(100000201, 5003012, 201), // Player Contract
|
||||
staff(100000427, 3000083, 8), // Fitness Coach
|
||||
];
|
||||
let rep = analyze(
|
||||
&profile(&items, "[]", 100000500),
|
||||
&roster(),
|
||||
&entities(),
|
||||
&none(),
|
||||
);
|
||||
assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers());
|
||||
assert_eq!(rep.non_player.supported.len(), 2);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sum = emit_content(&rep, dir.path(), "fp").unwrap();
|
||||
assert_eq!(sum.definitions, 1, "one player definition");
|
||||
assert_eq!(sum.non_player_definitions, 2);
|
||||
assert_eq!(sum.non_player_instances, 2);
|
||||
assert_eq!(
|
||||
sum.catalog_entries, 3,
|
||||
"player + 2 non-player catalog entries"
|
||||
);
|
||||
|
||||
// Content pack: neutral non-player CardDefinition with honest name.
|
||||
let pack: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&sum.content_pack).unwrap()).unwrap();
|
||||
let arr = pack.as_array().unwrap();
|
||||
let cons = arr.iter().find(|c| c["id"] == "fifa17_5003012").unwrap();
|
||||
assert_eq!(cons["name"], "Player Contract");
|
||||
assert_eq!(cons["overall"], 0);
|
||||
assert_eq!(cons["position"], "");
|
||||
assert_eq!(cons["nation"], "");
|
||||
assert_eq!(cons["rarity"], "bronze");
|
||||
assert!(cons["image_path"].is_null());
|
||||
|
||||
// Catalog: kind+subtype on player AND non-player; staff asset falls back to
|
||||
// resourceId; and the emitted catalog LOADS in the adapter with kind_of.
|
||||
let cat: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&sum.host_catalog).unwrap()).unwrap();
|
||||
assert_eq!(cat["cards"]["fifa17_20801"]["kind"], "player");
|
||||
assert_eq!(cat["cards"]["fifa17_20801"]["subtype"], 0);
|
||||
assert_eq!(cat["cards"]["fifa17_5003012"]["kind"], "consumable");
|
||||
assert_eq!(cat["cards"]["fifa17_5003012"]["subtype"], 201);
|
||||
assert_eq!(cat["cards"]["fifa17_5003012"]["rareflag"], 0);
|
||||
assert_eq!(cat["cards"]["fifa17_3000083"]["kind"], "staff");
|
||||
assert_eq!(cat["cards"]["fifa17_3000083"]["subtype"], 8);
|
||||
assert_eq!(cat["cards"]["fifa17_3000083"]["asset_id"], 3000083);
|
||||
|
||||
let loaded = Fifa17CardCatalog::from_file(&sum.host_catalog).unwrap();
|
||||
assert_eq!(loaded.kind_of("fifa17_20801"), ContentKind::Player);
|
||||
assert_eq!(loaded.kind_of("fifa17_5003012"), ContentKind::Consumable);
|
||||
assert_eq!(loaded.subtype_of("fifa17_5003012"), 201);
|
||||
assert_eq!(loaded.kind_of("fifa17_3000083"), ContentKind::Staff);
|
||||
|
||||
// Manifest: private non_player section with preserved wire ids.
|
||||
let man: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&sum.manifest).unwrap()).unwrap();
|
||||
assert_eq!(man["non_player"]["supported_instances"], 2);
|
||||
let np = man["non_player"]["supported_definitions"]
|
||||
.as_array()
|
||||
.unwrap();
|
||||
assert_eq!(np.len(), 2);
|
||||
let cons_man = np
|
||||
.iter()
|
||||
.find(|d| d["card_id"] == "fifa17_5003012")
|
||||
.unwrap();
|
||||
assert_eq!(cons_man["wire_ids"], serde_json::json!([100000201]));
|
||||
assert_eq!(cons_man["kind"], "consumable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_apply_mints_non_player_owned_instances() {
|
||||
let items = vec![
|
||||
player(100000001, 20801, 20801, 94),
|
||||
consumable(100000201, 5003012, 201),
|
||||
staff(100000427, 3000083, 8),
|
||||
];
|
||||
let (report, raw) = report_and_raw(&items, "[]", 100000500);
|
||||
let plan = plan_apply(&report, &raw, "fp").unwrap();
|
||||
// 1 player + 2 non-player owned instances, minted via the identical path.
|
||||
assert_eq!(plan.request.owned.len(), 3);
|
||||
assert_eq!(plan.mappings.len(), 3);
|
||||
assert_eq!(plan.supported_instances, 3);
|
||||
assert_eq!(plan.deferred_instances, 0);
|
||||
let cards: BTreeSet<&str> = plan
|
||||
.request
|
||||
.owned
|
||||
.iter()
|
||||
.map(|o| o.card_id.as_str())
|
||||
.collect();
|
||||
assert!(cards.contains("fifa17_5003012"), "consumable minted");
|
||||
assert!(cards.contains("fifa17_3000083"), "staff minted");
|
||||
// Deterministic OwnedItemId per (persona, wire) — same rule as players.
|
||||
let m = plan
|
||||
.mappings
|
||||
.iter()
|
||||
.find(|m| m.wire_id == 100000201)
|
||||
.unwrap();
|
||||
assert_eq!(m.core_id, owned_item_id(33068179, 100000201));
|
||||
|
||||
// Local preflight passes because the emitted content pack contains the
|
||||
// non-player card_ids too.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sum = emit_content(&report, dir.path(), "fp").unwrap();
|
||||
let ids = content_card_ids(&sum.content_pack).unwrap();
|
||||
local_core_preflight(&plan, &ids).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_non_player_instances_gate_a_production_apply() {
|
||||
// A supported player + a deferred (unknown-subtype) consumable: the deferred
|
||||
// non-player instance blocks a production apply, allowed only for staging.
|
||||
let items = vec![
|
||||
player(100000001, 20801, 20801, 94),
|
||||
consumable(100000300, 5009999, 999),
|
||||
];
|
||||
let (report, raw) = report_and_raw(&items, "[]", 100000500);
|
||||
let plan = plan_apply(&report, &raw, "fp").unwrap();
|
||||
assert_eq!(plan.deferred_instances, 1, "the deferred consumable counts");
|
||||
assert!(gate_staging(&plan, false).is_err(), "production blocks");
|
||||
assert!(gate_staging(&plan, true).unwrap(), "staging opt-in allows");
|
||||
}
|
||||
|
||||
+1
-1
Submodule openfut-launcher updated: ca7ce267a0...c2772132c1
@@ -233,32 +233,11 @@ pub fn serve(cfg: RedirectorConfig) -> std::io::Result<()> {
|
||||
bind(cfg)?.run()
|
||||
}
|
||||
|
||||
/// What a peer did with the connection before any TLS was attempted.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PeerOpening {
|
||||
/// Connected and closed without sending anything: a reachability probe.
|
||||
ClosedWithoutSpeaking,
|
||||
/// Sent at least one byte, so a real handshake is under way.
|
||||
Spoke,
|
||||
/// Timed out or errored. Deliberately NOT treated as a probe — a slow or
|
||||
/// broken client must still reach the acceptor and produce a real
|
||||
/// diagnostic, because misclassifying a fault as a probe would hide
|
||||
/// precisely what this distinction exists to protect.
|
||||
Undetermined,
|
||||
}
|
||||
|
||||
/// Classify the result of peeking at the first byte.
|
||||
///
|
||||
/// Split out as a pure function so the policy is testable without a socket —
|
||||
/// the interesting cases (EOF vs timeout) are awkward to provoke live and easy
|
||||
/// to get backwards.
|
||||
pub fn classify_opening(peek: &std::io::Result<usize>) -> PeerOpening {
|
||||
match peek {
|
||||
Ok(0) => PeerOpening::ClosedWithoutSpeaking,
|
||||
Ok(_) => PeerOpening::Spoke,
|
||||
Err(_) => PeerOpening::Undetermined,
|
||||
}
|
||||
}
|
||||
/// Re-exported from the shared TLS crate. The classification policy now lives in
|
||||
/// `openfut-tls` so every FIFA-facing TLS host shares one implementation; this
|
||||
/// host keeps naming them here so its public API and `probe_classification`
|
||||
/// integration test are unaffected.
|
||||
pub use openfut_tls::{classify_opening, PeerOpening};
|
||||
|
||||
fn handle(
|
||||
stream: TcpStream,
|
||||
@@ -285,8 +264,7 @@ fn handle(
|
||||
// exactly how the certificate mismatch that cost three live gates
|
||||
// presented, so a benign probe forging it poisons the one channel this
|
||||
// project gates on. Classified here, the two are never confused again.
|
||||
let mut first = [0u8; 1];
|
||||
if classify_opening(&stream.peek(&mut first)) == PeerOpening::ClosedWithoutSpeaking {
|
||||
if openfut_tls::peer_opening(&stream) == PeerOpening::ClosedWithoutSpeaking {
|
||||
probes.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
log(&format!(
|
||||
"conn-{id:04} {peer} PROBE: closed before sending a ClientHello (not a TLS fault)"
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -52,7 +52,7 @@ use openfut_adapter_fifa17::roster::{self, Method};
|
||||
use openfut_http::drain_body;
|
||||
pub use openfut_http::BodyRead;
|
||||
// The acceptor comes from the shared crate, so this host never names OpenSSL.
|
||||
use openfut_tls::SslAcceptor;
|
||||
use openfut_tls::{peer_opening, PeerOpening, SslAcceptor};
|
||||
|
||||
pub mod config;
|
||||
pub use config::RosterConfig;
|
||||
@@ -112,6 +112,15 @@ pub struct ConnOutcome {
|
||||
|
||||
pub type Outcomes = Arc<std::sync::Mutex<Vec<ConnOutcome>>>;
|
||||
|
||||
/// How many bare port probes have been classified before reaching the acceptor.
|
||||
///
|
||||
/// Counted, not only logged, for the same reason [`ConnOutcome`] exists: a test
|
||||
/// that asserts on client-visible symptoms cannot tell a probe that was
|
||||
/// classified from one that merely failed quietly, so removing the
|
||||
/// classification would leave the suite green. This makes it assertable — and
|
||||
/// mirrors the redirector, the host that first needed the distinction.
|
||||
pub type ProbeCount = Arc<AtomicUsize>;
|
||||
|
||||
/// Bounded: a host polled every few seconds must not accumulate forever.
|
||||
const OUTCOME_HISTORY: usize = 64;
|
||||
|
||||
@@ -130,6 +139,7 @@ pub struct Server {
|
||||
acceptor: Arc<SslAcceptor>,
|
||||
cfg: Arc<RosterConfig>,
|
||||
outcomes: Outcomes,
|
||||
probes: ProbeCount,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
@@ -137,6 +147,11 @@ impl Server {
|
||||
self.outcomes.clone()
|
||||
}
|
||||
|
||||
/// A handle to the bare-probe counter, obtainable before [`Server::run`].
|
||||
pub fn probes(&self) -> ProbeCount {
|
||||
self.probes.clone()
|
||||
}
|
||||
|
||||
pub fn run(self) -> std::io::Result<()> {
|
||||
let counter = AtomicU64::new(0);
|
||||
for incoming in self.listener.incoming() {
|
||||
@@ -144,7 +159,8 @@ impl Server {
|
||||
let id = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let (acceptor, cfg) = (self.acceptor.clone(), self.cfg.clone());
|
||||
let outcomes = self.outcomes.clone();
|
||||
std::thread::spawn(move || handle(stream, id, &acceptor, &cfg, &outcomes));
|
||||
let probes = self.probes.clone();
|
||||
std::thread::spawn(move || handle(stream, id, &acceptor, &cfg, &outcomes, &probes));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -184,6 +200,7 @@ pub fn bind(cfg: RosterConfig) -> std::io::Result<Server> {
|
||||
acceptor: Arc::new(acceptor),
|
||||
cfg: Arc::new(cfg),
|
||||
outcomes: Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||
probes: ProbeCount::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -207,12 +224,25 @@ fn handle(
|
||||
acceptor: &SslAcceptor,
|
||||
cfg: &RosterConfig,
|
||||
outcomes: &Outcomes,
|
||||
probes: &ProbeCount,
|
||||
) {
|
||||
let peer = stream
|
||||
.peer_addr()
|
||||
.map(|a| a.to_string())
|
||||
.unwrap_or_else(|_| "?".into());
|
||||
|
||||
// Classify a bare port probe BEFORE the acceptor sees it. A `connect` then
|
||||
// drop (the launcher's preflight makes one per run) otherwise reaches the
|
||||
// acceptor as `unexpected EOF` — byte-identical to the certificate mismatch
|
||||
// that cost three live gates. Shared with the redirector via openfut-tls so
|
||||
// the two hosts can never diverge on this.
|
||||
if peer_opening(&stream) == PeerOpening::ClosedWithoutSpeaking {
|
||||
probes.fetch_add(1, Ordering::Relaxed);
|
||||
log(&format!(
|
||||
"conn-{id:04} {peer} PROBE: closed before sending a ClientHello (not a TLS fault)"
|
||||
));
|
||||
return;
|
||||
}
|
||||
let mut tls = match acceptor.accept(stream) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! A bare port probe must not be reported as a TLS fault on the roster host.
|
||||
//!
|
||||
//! `TLS HANDSHAKE FAILED: ... unexpected EOF` is the exact signature the
|
||||
//! certificate mismatch produced — the defect that cost three live gate attempts
|
||||
//! and was invisible everywhere else. A reachability probe forges it trivially:
|
||||
//! `TcpStream::connect` then drop opens the connection and closes it without
|
||||
//! sending a byte, which the acceptor reports as `unexpected EOF`. The launcher's
|
||||
//! preflight makes such probes, so the roster host — like the redirector — must
|
||||
//! classify the opening before the acceptor sees it. The policy is shared via
|
||||
//! `openfut-tls`; this proves the roster host actually applies it.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use openfut_roster_host::{bind, RosterConfig};
|
||||
|
||||
fn cert_pair() -> (String, String) {
|
||||
let base = concat!(env!("CARGO_MANIFEST_DIR"), "/../fifa17-recon/tools");
|
||||
(
|
||||
format!("{base}/redir_cert.pem"),
|
||||
format!("{base}/redir_key.pem"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Start a host on an ephemeral port; hand back its address and its counters.
|
||||
fn start() -> (
|
||||
std::net::SocketAddr,
|
||||
openfut_roster_host::ProbeCount,
|
||||
openfut_roster_host::Outcomes,
|
||||
) {
|
||||
let (c, k) = cert_pair();
|
||||
let server = bind(RosterConfig::for_test(&c, &k)).expect("host binds");
|
||||
let addr = server.local_addr;
|
||||
let (probes, outcomes) = (server.probes(), server.outcomes());
|
||||
std::thread::spawn(move || {
|
||||
let _ = server.run();
|
||||
});
|
||||
(addr, probes, outcomes)
|
||||
}
|
||||
|
||||
/// A FIFA-like client: legacy suites, no certificate checking. Sends a GET and
|
||||
/// reads the response to EOF.
|
||||
fn tls_get(addr: std::net::SocketAddr) -> Vec<u8> {
|
||||
use openfut_tls::{SslConnector, SslMethod, SslVerifyMode};
|
||||
let mut b = SslConnector::builder(SslMethod::tls()).expect("connector");
|
||||
b.set_cipher_list(openfut_adapter_fifa17::tls::OBSERVED_CLIENT_SUITES)
|
||||
.expect("ciphers");
|
||||
b.set_verify(SslVerifyMode::NONE);
|
||||
let sock = TcpStream::connect(addr).expect("connect");
|
||||
let ssl = b
|
||||
.build()
|
||||
.configure()
|
||||
.and_then(|c| c.verify_hostname(false).into_ssl("roster-test"))
|
||||
.expect("ssl");
|
||||
let mut s = openfut_tls::SslStream::new(ssl, sock).expect("stream");
|
||||
s.connect().expect("handshake");
|
||||
s.write_all(
|
||||
b"GET /fifa17/fut/rosterupdate.xml HTTP/1.1\r\nHost: roster-test\r\nAccept: */*\r\n\r\n",
|
||||
)
|
||||
.expect("write");
|
||||
s.flush().ok();
|
||||
let mut out = Vec::new();
|
||||
let _ = s.read_to_end(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// The whole point: connect, send nothing, close — classified as a probe rather
|
||||
/// than reaching the acceptor. Asserting on the counter (not on client-visible
|
||||
/// behaviour) is deliberate: a probe produces no response either way, so a test
|
||||
/// on what the client sees would pass with the classification deleted.
|
||||
#[test]
|
||||
fn a_bare_connect_and_close_is_classified_as_a_probe() {
|
||||
let (addr, probes, outcomes) = start();
|
||||
|
||||
drop(TcpStream::connect(addr).expect("probe connects"));
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
|
||||
assert_eq!(
|
||||
probes.load(Ordering::Relaxed),
|
||||
1,
|
||||
"a connect-and-close was not classified as a probe"
|
||||
);
|
||||
assert!(
|
||||
outcomes.lock().expect("lock").is_empty(),
|
||||
"a probe must not be recorded as a served connection"
|
||||
);
|
||||
}
|
||||
|
||||
/// A probe must not disturb the host: the next real client still gets served.
|
||||
#[test]
|
||||
fn a_probe_does_not_break_the_connection_that_follows_it() {
|
||||
let (addr, probes, _outcomes) = start();
|
||||
|
||||
drop(TcpStream::connect(addr).expect("probe connects"));
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
|
||||
let response = tls_get(addr);
|
||||
assert!(
|
||||
response.starts_with(b"HTTP/1.0 200"),
|
||||
"real client after a probe got: {:?}",
|
||||
String::from_utf8_lossy(response.get(..64).unwrap_or(&response))
|
||||
);
|
||||
assert_eq!(
|
||||
probes.load(Ordering::Relaxed),
|
||||
1,
|
||||
"the real client was miscounted as a probe"
|
||||
);
|
||||
}
|
||||
|
||||
/// The dangerous direction: a client that DOES speak and then fails must still
|
||||
/// reach the acceptor and be reported as a fault, not silently reclassified as a
|
||||
/// benign probe.
|
||||
#[test]
|
||||
fn a_client_that_speaks_then_fails_is_not_a_probe() {
|
||||
let (addr, probes, _outcomes) = start();
|
||||
|
||||
let mut sock = TcpStream::connect(addr).expect("connect");
|
||||
// One byte of nonsense: the peer has spoken, but it is not a ClientHello, so
|
||||
// the handshake genuinely fails.
|
||||
sock.write_all(&[0x16]).expect("write");
|
||||
sock.flush().ok();
|
||||
drop(sock);
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
|
||||
assert_eq!(
|
||||
probes.load(Ordering::Relaxed),
|
||||
0,
|
||||
"a failing handshake was silently reclassified as a benign probe"
|
||||
);
|
||||
}
|
||||
@@ -54,6 +54,52 @@ impl fmt::Display for TlsError {
|
||||
|
||||
impl std::error::Error for TlsError {}
|
||||
|
||||
/// What a peer did with the connection before any TLS was attempted.
|
||||
///
|
||||
/// A bare reachability probe — `TcpStream::connect` then drop, which the
|
||||
/// launcher's preflight makes twice per run — opens the connection and closes
|
||||
/// without sending a byte. If that reaches the acceptor it fails as
|
||||
/// `unexpected EOF`, which is **byte-identical** to the signature of the
|
||||
/// certificate mismatch that cost three live gates. Classifying the opening
|
||||
/// before the acceptor sees it keeps a benign probe from forging a TLS fault in
|
||||
/// the one channel this project gates on. Every FIFA-facing TLS host shares this
|
||||
/// policy so the distinction can never regress in just one of them.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PeerOpening {
|
||||
/// Connected and closed without sending anything: a reachability probe.
|
||||
ClosedWithoutSpeaking,
|
||||
/// Sent at least one byte, so a real handshake is under way.
|
||||
Spoke,
|
||||
/// Timed out or errored. Deliberately NOT treated as a probe — a slow or
|
||||
/// broken client must still reach the acceptor and produce a real
|
||||
/// diagnostic, because misclassifying a fault as a probe would hide
|
||||
/// precisely what this distinction exists to protect.
|
||||
Undetermined,
|
||||
}
|
||||
|
||||
/// Classify the result of peeking at the first byte.
|
||||
///
|
||||
/// Split out as a pure function so the policy is testable without a socket —
|
||||
/// the interesting cases (EOF vs timeout) are awkward to provoke live and easy
|
||||
/// to get backwards.
|
||||
pub fn classify_opening(peek: &std::io::Result<usize>) -> PeerOpening {
|
||||
match peek {
|
||||
Ok(0) => PeerOpening::ClosedWithoutSpeaking,
|
||||
Ok(_) => PeerOpening::Spoke,
|
||||
Err(_) => PeerOpening::Undetermined,
|
||||
}
|
||||
}
|
||||
|
||||
/// Peek one byte to classify a connection before the TLS acceptor sees it.
|
||||
///
|
||||
/// `MSG_PEEK` leaves the byte in the receive queue, so a subsequent
|
||||
/// `acceptor.accept(stream)` reads the ClientHello intact — this is
|
||||
/// non-destructive for a real handshake and only short-circuits a bare probe.
|
||||
pub fn peer_opening(stream: &std::net::TcpStream) -> PeerOpening {
|
||||
let mut first = [0u8; 1];
|
||||
classify_opening(&stream.peek(&mut first))
|
||||
}
|
||||
|
||||
/// A TLS protocol version, expressed without an OpenSSL type.
|
||||
///
|
||||
/// Adapters name the version window their client was observed to use; keeping
|
||||
@@ -299,6 +345,19 @@ pub fn self_test(cfg: &TlsConfig, client_ciphers: &str, sni: &str) -> Result<Neg
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classify_opening_maps_each_case() {
|
||||
// EOF before any byte is the reachability probe this exists to catch.
|
||||
assert_eq!(classify_opening(&Ok(0)), PeerOpening::ClosedWithoutSpeaking);
|
||||
assert_eq!(classify_opening(&Ok(1)), PeerOpening::Spoke);
|
||||
// A timeout must NOT be a probe: a slow or broken client still deserves
|
||||
// a real diagnostic from the acceptor, not a silent probe reclassification.
|
||||
assert_eq!(
|
||||
classify_opening(&Err(std::io::Error::from(std::io::ErrorKind::WouldBlock))),
|
||||
PeerOpening::Undetermined
|
||||
);
|
||||
}
|
||||
|
||||
/// A self-signed pair, generated here rather than borrowed from the game
|
||||
/// stack: this crate is game-independent and its tests must not depend on
|
||||
/// FIFA material. Adapter crates test their own profiles.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+943
-114
File diff suppressed because it is too large
Load Diff
+587
-104
@@ -26,9 +26,14 @@
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use openfut_adapter_fifa17::fut::entities::ReverseEntityResolver;
|
||||
use openfut_adapter_fifa17::fut::item::{shape_item, ItemIdentityResolver};
|
||||
use openfut_adapter_fifa17::fut::non_economy;
|
||||
use openfut_adapter_fifa17::fut::squad::SquadWireResolver;
|
||||
|
||||
use crate::market_store::{Listing, MarketError, MarketStore};
|
||||
use crate::economy_store::OwnedItemLookup;
|
||||
|
||||
use crate::market_store::{now_secs, Listing, MarketError, MarketStore};
|
||||
use crate::pile_store::PileStore;
|
||||
use crate::{CoreEconomy, CoreError, WireResponse};
|
||||
|
||||
@@ -66,37 +71,105 @@ fn trade_id_from_path(path: &str) -> Option<String> {
|
||||
|
||||
/// Shape one listing into the FIFA auction record (0x18013e410 fields), sourced
|
||||
/// from durable listing state rather than a hardcoded sample pool.
|
||||
fn auction_record(l: &Listing) -> Value {
|
||||
///
|
||||
/// `itemData` MUST be a full card object — the same proven-safe shape that renders
|
||||
/// club/squad cards (the Python oracle's tradePile reuses its club card verbatim).
|
||||
/// A 4-field stub gives the client's card view-model nothing to draw, so the
|
||||
/// Transfer List shows a row with no visible card. The full card comes from the
|
||||
/// snapshot persisted at listing time; a row written before snapshots existed
|
||||
/// degrades to the stub (honest, not fabricated).
|
||||
///
|
||||
/// `item_state` overrides the card's `itemState`: the seller's own pile uses
|
||||
/// `listFS` (list-for-sale), market search results use `forSale` — the oracle
|
||||
/// distinguishes these, so the caller passes the one its screen needs.
|
||||
fn auction_record_as(l: &Listing, item_state: &str) -> Value {
|
||||
let trade_id: i64 = l.listing_id.parse().unwrap_or(0);
|
||||
// resourceId is the FIFA wire identity the client listed (never the Core
|
||||
// card id). 0 means "no art", a valid int — never a fabricated FIFA asset.
|
||||
let resource = l.wire_resource_id.or(l.wire_item_id).unwrap_or(0);
|
||||
let item_id = l.wire_item_id.unwrap_or(trade_id);
|
||||
let (trade_state, item_state, bid_state, current_bid) = match l.state.as_str() {
|
||||
"active" => ("active", "forSale", "none", 0),
|
||||
_ => ("closed", "free", "highest", l.buy_now_price),
|
||||
// `expires` is SECONDS REMAINING (a 64-bit int), never an epoch, and the
|
||||
// client renders a LIVE COUNTDOWN from it and expects it to reach 0. A frozen
|
||||
// constant is therefore wrong on the wire even though it renders: the auction
|
||||
// never appears to age. Derived from the stored creation time plus the
|
||||
// client-supplied listing duration.
|
||||
let expires = l.expires_in_secs(now_secs());
|
||||
// An unsold auction whose clock has run out reads `expired`/`none` with
|
||||
// `expires: 0` — that is FIFA 17's relistable state. Both vocabularies are
|
||||
// closed sets read out of the client: `tradeState` decodes through a table
|
||||
// walk (`active=1 inactive=2 expired=3 closed=4`, anything else -1) and
|
||||
// `bidState` through a strcmp ladder (`none=0 outbid=1 highest=2 buyNow=3`,
|
||||
// anything else silently `none`). NEVER invent a state string — an
|
||||
// unrecognised one is swallowed as `none` and produces a plausible-looking
|
||||
// but wrong UI. There is no `won`, `lost` or `sold`.
|
||||
//
|
||||
// This is a pure PROJECTION: no row is mutated, so nothing here can race the
|
||||
// economy or need a background sweeper.
|
||||
let (trade_state, bid_state, current_bid) = match l.state.as_str() {
|
||||
"active" if expires == 0 => ("expired", "none", 0),
|
||||
"active" => ("active", "none", 0),
|
||||
_ => ("closed", "highest", l.buy_now_price),
|
||||
};
|
||||
let item_data = l
|
||||
.item_json
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<Value>(s).ok())
|
||||
.filter(Value::is_object)
|
||||
.map(|mut card| {
|
||||
// Keep the wire identity and presentation state authoritative here.
|
||||
card["id"] = json!(item_id);
|
||||
card["itemState"] = json!(item_state);
|
||||
card["untradeable"] = json!(false);
|
||||
card
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
json!({
|
||||
"tradeId": trade_id,
|
||||
"itemData": {
|
||||
"id": item_id,
|
||||
"resourceId": resource,
|
||||
"itemState": item_state,
|
||||
"untradeable": false,
|
||||
},
|
||||
})
|
||||
});
|
||||
// EXACTLY the twelve fields FIFA 17's auctionInfo deserializer (0x18013e410)
|
||||
// reads. Everything else falls through to its value-SKIP at 0x180135ff0, so an
|
||||
// extra key is not "harmless richness" — it is dead weight that misleads the
|
||||
// next reader about what the client consumes.
|
||||
//
|
||||
// In particular `tradeOwner` / `sellerId` / `offers` are NOT read by FIFA 17.
|
||||
// They were added here on the strength of contemporaneous FIFA 17 libraries
|
||||
// and are refuted by the PE's own atom table (see
|
||||
// docs/FIFA17_TRANSFER_MARKET_WIRE.md): the client cannot be told "this
|
||||
// auction is yours" through the record at all, so ownership is NOT the gate on
|
||||
// the Transfer List Actions panel.
|
||||
json!({
|
||||
"tradeId": trade_id,
|
||||
"itemData": item_data,
|
||||
"tradeState": trade_state,
|
||||
"buyNowPrice": l.buy_now_price,
|
||||
"startingBid": l.start_price,
|
||||
"currentBid": current_bid,
|
||||
"bidState": bid_state,
|
||||
"expires": 3600,
|
||||
"sellerName": l.owner.clone().unwrap_or_else(|| "EASFC".to_string()),
|
||||
"expires": expires,
|
||||
// Bounded copy, max 30 chars. The oracle stamps the player's persona here
|
||||
// and `fut_account.py` annotates that property as "UTAS sellerName", so
|
||||
// EA's house name would make the player's own listing look foreign.
|
||||
"sellerName": l
|
||||
.owner
|
||||
.clone()
|
||||
.unwrap_or_else(|| non_economy::PERSONA_DISPLAY_NAME.to_string()),
|
||||
"sellerEstablished": 1,
|
||||
"watched": false,
|
||||
"coinsProcessed": 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Auction record for a market/search context (`itemState: forSale`), and for the
|
||||
/// closed/sold echoes the buy path returns.
|
||||
fn auction_record(l: &Listing) -> Value {
|
||||
let state = if l.state == "active" { "forSale" } else { "free" };
|
||||
auction_record_as(l, state)
|
||||
}
|
||||
|
||||
/// Run a BLOCKING closure — the blocking Core client — on a fresh OS thread that
|
||||
/// has NO Tokio runtime entered, then block until it finishes. The market
|
||||
/// handlers are `async` and driven on the shared runtime by the host bridge, but
|
||||
@@ -119,74 +192,158 @@ fn credits_or_zero(econ: &dyn CoreEconomy) -> i64 {
|
||||
off_runtime(|| econ.balance()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Maps a FIFA wire `resourceId` to the authoritative Core `card_id` a synthetic
|
||||
/// buy mints. Backed by the FIFA17 catalog reverse index; unknown → `None`
|
||||
/// (fail closed, never fabricated). Core stays unaware of FIFA resource ids.
|
||||
pub trait MarketCardResolver: Send + Sync {
|
||||
fn card_id_for_resource(&self, resource_id: i64) -> Option<String>;
|
||||
/// A FutISStart POST resolved to a persistable listing — all owned/`Send` data,
|
||||
/// so it can cross the async persist boundary. Built by [`resolve_market_list`]
|
||||
/// on the caller's thread from Core inventory (the request body carries only the
|
||||
/// wire item id, never the resourceId or Core card_id).
|
||||
pub struct ResolvedListing {
|
||||
pub item_id: i64,
|
||||
pub core_id: String,
|
||||
pub card_id: String,
|
||||
pub resource_id: Option<i64>,
|
||||
pub start: i64,
|
||||
pub buy_now: i64,
|
||||
pub seller: Option<String>,
|
||||
/// The full shaped FIFA card (`itemData`) snapshot for the auction record.
|
||||
/// `None` only when the item has no resolvable FIFA identity (never faked).
|
||||
pub item_json: Option<String>,
|
||||
/// Listing duration in seconds from the client's body. `None` when the client
|
||||
/// omits it, which falls back to the store's default.
|
||||
pub duration: Option<i64>,
|
||||
}
|
||||
|
||||
/// Resolve a `/auctionhouse` POST (FutISStart) body to a [`ResolvedListing`],
|
||||
/// SERVER-SIDE: the client's body carries only the wire item id, so the owned
|
||||
/// card's Core `card_id` (minted on a synthetic buy) and FIFA `resourceId` (the
|
||||
/// auction record) come from Core inventory. Returns `None` when the body names
|
||||
/// no item OR the id does not reverse-resolve to an owned card — either way the
|
||||
/// caller acks a fresh trade id and persists nothing (Core stays the ownership
|
||||
/// authority; a phantom listing would mint a card the buy preflight rejects).
|
||||
/// Pure identity + a single Core inventory read; run it OFF the async runtime.
|
||||
pub fn resolve_market_list<E: ReverseEntityResolver>(
|
||||
body: &[u8],
|
||||
reverse: &dyn SquadWireResolver,
|
||||
resolver: &dyn ItemIdentityResolver,
|
||||
items: &dyn OwnedItemLookup,
|
||||
ent: &E,
|
||||
) -> Option<ResolvedListing> {
|
||||
let b = parse_body(body);
|
||||
let item_data = b.get("itemData");
|
||||
let item_id = item_data
|
||||
.and_then(|d| d.get("id"))
|
||||
.and_then(Value::as_i64)
|
||||
.or_else(|| b.get("itemId").and_then(Value::as_i64))?;
|
||||
let core_id = reverse.owned_id_for_wire(item_id)?;
|
||||
let owned = items.owned_item(&core_id)?;
|
||||
// Shape the FULL card ONCE, here, with the same shaper `/club` and the squad
|
||||
// projection use — so the auction record renders identically to the club card.
|
||||
// A listing is a snapshot; storing it avoids re-resolving on every tradePile
|
||||
// poll and keeps the (non-Send) resolvers off the async persist path.
|
||||
let identity = resolver.resolve(&owned);
|
||||
let resource_id = identity.map(|id| id.resource_id as i64);
|
||||
let item_json = identity
|
||||
.map(|id| shape_item(&owned, id, ent))
|
||||
.and_then(|card| serde_json::to_string(&card).ok());
|
||||
Some(ResolvedListing {
|
||||
item_id,
|
||||
core_id,
|
||||
card_id: owned.card_id,
|
||||
resource_id,
|
||||
start: b.get("startingBid").and_then(Value::as_i64).unwrap_or(150),
|
||||
buy_now: b.get("buyNowPrice").and_then(Value::as_i64).unwrap_or(0),
|
||||
seller: b.get("sellerName").and_then(Value::as_str).map(str::to_string),
|
||||
item_json,
|
||||
// FIFA 17's ISStart body carries the listing duration in seconds. We
|
||||
// previously dropped it and reported a frozen `expires`, so the client's
|
||||
// countdown never moved and an auction could never run out.
|
||||
duration: b.get("duration").and_then(Value::as_i64).filter(|d| *d > 0),
|
||||
})
|
||||
}
|
||||
|
||||
/// `/auctionhouse` — search (GET), list-for-sale (POST FutISStart), relist (PUT).
|
||||
///
|
||||
/// * GET returns the durable active auctions plus the FutGetAuctionCount ints,
|
||||
/// in the oracle's `_market_body` shape.
|
||||
/// * POST lists a club item: resolve its wire `resourceId` to the authoritative
|
||||
/// Core `card_id`, persist both, and return `{"id": tradeId}`. An unmappable
|
||||
/// resource fails closed (persists nothing).
|
||||
/// * POST persists the pre-resolved listing (see [`resolve_market_list`]) and
|
||||
/// returns `{"id": tradeId}`; an unresolved item acks a fresh id, persisting
|
||||
/// nothing.
|
||||
/// * PUT (relist-all) is an ack `{}`.
|
||||
pub async fn handle_market_list(
|
||||
method: &str,
|
||||
body: &[u8],
|
||||
resolved: Option<ResolvedListing>,
|
||||
econ: &dyn CoreEconomy,
|
||||
store: &MarketStore,
|
||||
mapper: &dyn MarketCardResolver,
|
||||
) -> WireResponse {
|
||||
match method {
|
||||
"POST" => {
|
||||
let b = parse_body(body);
|
||||
let item_data = b.get("itemData");
|
||||
let wire_item_id = item_data
|
||||
.and_then(|d| d.get("id"))
|
||||
.and_then(Value::as_i64)
|
||||
.or_else(|| b.get("itemId").and_then(Value::as_i64));
|
||||
let start = b.get("startingBid").and_then(Value::as_i64).unwrap_or(150);
|
||||
let buy_now = b.get("buyNowPrice").and_then(Value::as_i64).unwrap_or(0);
|
||||
let Some(item_id) = wire_item_id else {
|
||||
// No item to list: mirror the oracle's fresh-id ack, persist nothing.
|
||||
return ok_json(&json!({ "id": TRADE_ID_BASE }));
|
||||
};
|
||||
// Resolve the FIFA wire resourceId to the authoritative Core card id
|
||||
// the synthetic buy will MINT. Fail closed on an unmappable resource:
|
||||
// persist nothing (a non-existent listing cannot be bought), so a bad
|
||||
// resource never becomes a mint Core's content preflight would reject.
|
||||
let Some(resource_id) = item_data
|
||||
.and_then(|d| d.get("resourceId"))
|
||||
.and_then(Value::as_i64)
|
||||
else {
|
||||
return ok_json(&json!({ "id": TRADE_ID_BASE }));
|
||||
};
|
||||
let Some(core_card_id) = mapper.card_id_for_resource(resource_id) else {
|
||||
let Some(r) = resolved else {
|
||||
// No item, or the id did not resolve to an owned card: fresh-id ack.
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=market-list POST listed=false reason=unresolved"
|
||||
);
|
||||
return ok_json(&json!({ "id": TRADE_ID_BASE }));
|
||||
};
|
||||
// Trade-id space is offset from the wire item id, so each owned item
|
||||
// maps to a unique, stable auction id (no modular wraparound).
|
||||
let trade_id = TRADE_ID_BASE + item_id;
|
||||
let trade_id = TRADE_ID_BASE + r.item_id;
|
||||
let listing_id = trade_id.to_string();
|
||||
let seller = b.get("sellerName").and_then(Value::as_str);
|
||||
match store
|
||||
.create_listing(
|
||||
&listing_id,
|
||||
&core_card_id,
|
||||
None,
|
||||
Some(item_id),
|
||||
Some(resource_id),
|
||||
start,
|
||||
buy_now,
|
||||
seller,
|
||||
&r.card_id,
|
||||
Some(&r.core_id),
|
||||
Some(r.item_id),
|
||||
r.resource_id,
|
||||
r.start,
|
||||
r.buy_now,
|
||||
r.seller.as_deref(),
|
||||
r.item_json.as_deref(),
|
||||
r.duration,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) | Err(MarketError::Conflict) => ok_json(&json!({ "id": trade_id })),
|
||||
Ok(_) => {
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=market-list POST item_id={} listed=true trade_id={trade_id}",
|
||||
r.item_id
|
||||
);
|
||||
ok_json(&json!({ "id": trade_id }))
|
||||
}
|
||||
// A row for this item already exists, so this POST is a RELIST.
|
||||
// FIFA 17 relists by re-sending ISStart, so the PK conflict is the
|
||||
// normal relist path — NOT an error, and NOT a success to swallow.
|
||||
// Acking it without resetting the clock is why relisting an expired
|
||||
// card appeared to do nothing: the client got its id back while the
|
||||
// stale row stayed expired.
|
||||
Err(MarketError::Conflict) => {
|
||||
match store
|
||||
.relist_listing(
|
||||
&listing_id,
|
||||
r.start,
|
||||
r.buy_now,
|
||||
r.duration,
|
||||
r.item_json.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=market-list POST item_id={} relisted=true trade_id={trade_id}",
|
||||
r.item_id
|
||||
);
|
||||
ok_json(&json!({ "id": trade_id }))
|
||||
}
|
||||
// Sold or in-flight: never revive it. Ack so the screen does
|
||||
// not wedge, but say so plainly in the log.
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=market-list POST item_id={} relisted=false reason={e:?} trade_id={trade_id}",
|
||||
r.item_id
|
||||
);
|
||||
ok_json(&json!({ "id": trade_id }))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => json_body(503, &json!({ "error": "market_store" })),
|
||||
}
|
||||
}
|
||||
@@ -197,7 +354,10 @@ pub async fn handle_market_list(
|
||||
Ok(l) => l,
|
||||
Err(_) => return json_body(503, &json!({ "error": "market_store" })),
|
||||
};
|
||||
let auctions: Vec<Value> = listings.iter().map(auction_record).collect();
|
||||
let auctions: Vec<Value> = listings
|
||||
.iter()
|
||||
.map(auction_record)
|
||||
.collect();
|
||||
ok_json(&json!({
|
||||
"auctionInfo": auctions,
|
||||
"credits": credits_or_zero(econ),
|
||||
@@ -215,6 +375,10 @@ pub async fn handle_market_list(
|
||||
|
||||
/// Query listings in a given `state` (e.g. the user's own sale pile is the
|
||||
/// `active` set). Returns the oracle's tradePile shape.
|
||||
///
|
||||
/// This is the SELLER's own pile, so each card carries `itemState: "listFS"`
|
||||
/// (list-for-sale) — the state the oracle stamps on a tradePile card, distinct
|
||||
/// from the `forSale` used for market search results.
|
||||
pub async fn handle_market_query(
|
||||
state: &str,
|
||||
econ: &dyn CoreEconomy,
|
||||
@@ -224,11 +388,43 @@ pub async fn handle_market_query(
|
||||
Ok(l) => l,
|
||||
Err(_) => return json_body(503, &json!({ "error": "market_store" })),
|
||||
};
|
||||
let auctions: Vec<Value> = listings.iter().map(auction_record).collect();
|
||||
let auctions: Vec<Value> = listings
|
||||
.iter()
|
||||
.map(|l| auction_record_as(l, "listFS"))
|
||||
.collect();
|
||||
// GetTradePile shares one deserializer (0x18013e7f0) with ISSearch and
|
||||
// ISWatchList, over exactly four members: `auctionInfo` (array), `credits`
|
||||
// (int), `duplicateItemIdList` (array of objects) and `total` (int). We were
|
||||
// omitting `duplicateItemIdList`; `[]` is the safe, recommended value.
|
||||
ok_json(&json!({
|
||||
"auctionInfo": auctions,
|
||||
"credits": credits_or_zero(econ),
|
||||
"total": auctions.len(),
|
||||
"duplicateItemIdList": [],
|
||||
}))
|
||||
}
|
||||
|
||||
/// `GET …/tradePile/counts` — FutGetAuctionCount (the auction TALLY), a DISTINCT
|
||||
/// deserializer from `/tradePile`. It reads exactly five SCALAR INTS — `count`,
|
||||
/// `maxAuctionsAllowed`, `offered`, `selling`, `sold` — and skips anything else,
|
||||
/// so answering it with the `auctionInfo` listing body leaves every count at its
|
||||
/// constructor default (0): the hub tile shows a listing while the Transfer List
|
||||
/// screen shows none. All five being ints means there is no container-type
|
||||
/// freeze risk. They are the only inputs to IS_MAX_AUCTIONS, so
|
||||
/// `maxAuctionsAllowed = 100` with `selling < 100` keeps the listing cap open.
|
||||
/// A store read failure degrades to zeros (cosmetic tally, never fail-closed).
|
||||
pub async fn handle_market_counts(store: &MarketStore) -> WireResponse {
|
||||
let n = store
|
||||
.query_listings("active")
|
||||
.await
|
||||
.map(|l| l.len() as i64)
|
||||
.unwrap_or(0);
|
||||
ok_json(&json!({
|
||||
"count": n,
|
||||
"maxAuctionsAllowed": 100,
|
||||
"offered": 0,
|
||||
"selling": n,
|
||||
"sold": 0,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -248,6 +444,70 @@ pub async fn handle_market_cancel(
|
||||
ok_json(&json!({}))
|
||||
}
|
||||
|
||||
/// `GET …/trade/status` — live auction-state refresh for the rows a screen is
|
||||
/// showing. The Transfer List polls this CONTINUOUSLY while it is open.
|
||||
///
|
||||
/// This route previously fell through to the buy/view arm, where
|
||||
/// `trade_id_from_path("trade/status")` cannot parse an id, so every poll was
|
||||
/// answered with an EMPTY `auctionInfo` — the client kept asking for the state of
|
||||
/// its own listings and was told, repeatedly, that there was none. Observed
|
||||
/// directly in the live logs (`route=economy … path=…/trade/status` on repeat),
|
||||
/// so unlike the `tradeOwner` change this is a CONFIRMED defect, not a candidate.
|
||||
///
|
||||
/// `tradeIds` is a comma-separated filter; unknown ids are simply absent from the
|
||||
/// reply rather than erroring. With no filter we answer with the player's own
|
||||
/// active pile, which is the only auction set this single-account market has.
|
||||
pub async fn handle_market_status(
|
||||
query: Option<&str>,
|
||||
econ: &dyn CoreEconomy,
|
||||
store: &MarketStore,
|
||||
) -> WireResponse {
|
||||
let ids = trade_ids_from_query(query);
|
||||
let listings = if ids.is_empty() {
|
||||
match store.query_listings("active").await {
|
||||
Ok(l) => l,
|
||||
Err(_) => return json_body(503, &json!({ "error": "market_store" })),
|
||||
}
|
||||
} else {
|
||||
let mut found = Vec::with_capacity(ids.len());
|
||||
for id in &ids {
|
||||
if let Ok(l) = store.get_listing(id).await {
|
||||
found.push(l);
|
||||
}
|
||||
}
|
||||
found
|
||||
};
|
||||
let auctions: Vec<Value> = listings
|
||||
.iter()
|
||||
.map(|l| auction_record_as(l, "listFS"))
|
||||
.collect();
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=market-status requested={} returned={} query={}",
|
||||
ids.len(),
|
||||
auctions.len(),
|
||||
query.unwrap_or("")
|
||||
);
|
||||
ok_json(&json!({
|
||||
"auctionInfo": auctions,
|
||||
"credits": credits_or_zero(econ),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Parse `tradeIds=1,2,3` (also accepts repeated `tradeIds=`) out of a raw query
|
||||
/// string. Non-numeric entries are skipped rather than failing the whole poll.
|
||||
fn trade_ids_from_query(query: Option<&str>) -> Vec<String> {
|
||||
let Some(q) = query else {
|
||||
return Vec::new();
|
||||
};
|
||||
q.split('&')
|
||||
.filter_map(|kv| kv.split_once('='))
|
||||
.filter(|(k, _)| k.eq_ignore_ascii_case("tradeIds") || k.eq_ignore_ascii_case("tradeId"))
|
||||
.flat_map(|(_, v)| v.split(','))
|
||||
.filter(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `/trade/<id>` — view (GET) or buy-now / bid (POST/PUT). Buy-now is the
|
||||
/// synthetic-seller path: reserve (CAS) → Core `purchase_item` mint+debit →
|
||||
/// complete the sale; any Core failure rolls the reservation back.
|
||||
@@ -525,20 +785,55 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Permissive test resolver: maps any wire resourceId to its own string, so
|
||||
/// the existing list tests keep their prior card-id semantics. A dedicated
|
||||
/// test covers the unknown-resource fail-closed path.
|
||||
struct AllowAllResolver;
|
||||
impl MarketCardResolver for AllowAllResolver {
|
||||
fn card_id_for_resource(&self, resource_id: i64) -> Option<String> {
|
||||
Some(resource_id.to_string())
|
||||
// ---- ItemIdentityResolver + OwnedItemLookup doubles -------------------
|
||||
use openfut_adapter_fifa17::fut::item::{CoreOwnedItem, Fifa17Identity};
|
||||
|
||||
/// Owned-inventory double: core id -> CoreOwnedItem.
|
||||
struct FakeItems(HashMap<String, CoreOwnedItem>);
|
||||
impl OwnedItemLookup for FakeItems {
|
||||
fn owned_item(&self, core_id: &str) -> Option<CoreOwnedItem> {
|
||||
self.0.get(core_id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Test resolver that maps nothing (every resourceId is unknown).
|
||||
struct DenyAllResolver;
|
||||
impl MarketCardResolver for DenyAllResolver {
|
||||
fn card_id_for_resource(&self, _resource_id: i64) -> Option<String> {
|
||||
/// Identity double: resolves every owned item to a fixed FIFA resourceId.
|
||||
struct FixedIdentity(u32);
|
||||
impl ItemIdentityResolver for FixedIdentity {
|
||||
fn resolve(&self, _item: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||
Some(Fifa17Identity {
|
||||
item_id: 0,
|
||||
asset_id: self.0,
|
||||
resource_id: self.0,
|
||||
rareflag: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A CoreOwnedItem with the given core id + card id (other fields dummy).
|
||||
fn owned(core_id: &str, card_id: &str) -> CoreOwnedItem {
|
||||
CoreOwnedItem {
|
||||
owned_card_id: core_id.to_string(),
|
||||
card_id: card_id.to_string(),
|
||||
rating: 84,
|
||||
position: "ST".to_string(),
|
||||
nation: String::new(),
|
||||
league: String::new(),
|
||||
club: String::new(),
|
||||
attributes: [80, 80, 80, 80, 80, 80],
|
||||
}
|
||||
}
|
||||
|
||||
/// Entity double: no reverse entity mappings, so shaped cards carry ids 0
|
||||
/// (a valid int — the shaper never fabricates an entity id).
|
||||
struct NoEntities;
|
||||
impl ReverseEntityResolver for NoEntities {
|
||||
fn league_id(&self, _name: &str) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
fn nation_id(&self, _name: &str) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
fn team_id(&self, _name: &str) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -551,7 +846,7 @@ mod tests {
|
||||
|
||||
async fn seed_listing(store: &MarketStore, id: &str, buy_now: i64) {
|
||||
store
|
||||
.create_listing(id, "169193", None, None, Some(169193), 400, buy_now, None)
|
||||
.create_listing(id, "169193", None, None, Some(169193), 400, buy_now, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
@@ -566,23 +861,47 @@ mod tests {
|
||||
async fn list_post_persists_and_returns_trade_id() {
|
||||
let (store, _d) = store_at("post").await;
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
let body = json!({ "itemData": { "id": 100004617, "resourceId": 169193 },
|
||||
// The client body carries only the wire item id; the server resolves the
|
||||
// owned card's card_id + resourceId from Core inventory.
|
||||
let body = json!({ "itemData": { "id": 100004617 },
|
||||
"startingBid": 300, "buyNowPrice": 2500 });
|
||||
let resp = handle_market_list(
|
||||
"POST",
|
||||
let reverse = MapResolver::new(&[(100004617, "core-1")]);
|
||||
let items = FakeItems(
|
||||
[("core-1".to_string(), owned("core-1", "169193"))]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
);
|
||||
let ident = FixedIdentity(169193);
|
||||
let resolved = resolve_market_list(
|
||||
body.to_string().as_bytes(),
|
||||
&econ,
|
||||
&store,
|
||||
&AllowAllResolver,
|
||||
)
|
||||
.await;
|
||||
&reverse,
|
||||
&ident,
|
||||
&items,
|
||||
&NoEntities,
|
||||
);
|
||||
let resp = handle_market_list("POST", resolved, &econ, &store).await;
|
||||
assert_eq!(resp.status, 200);
|
||||
let trade_id = parse(&resp)["id"].as_i64().unwrap();
|
||||
assert_eq!(trade_id, TRADE_ID_BASE + 100004617);
|
||||
// Persisted + browsable.
|
||||
// Persisted with the resolved Core card_id + wire resourceId, browsable.
|
||||
let listed = store.get_listing(&trade_id.to_string()).await.unwrap();
|
||||
assert_eq!(listed.buy_now_price, 2500);
|
||||
let browse = handle_market_list("GET", b"", &econ, &store, &AllowAllResolver).await;
|
||||
assert_eq!(listed.card_id, "169193");
|
||||
assert_eq!(listed.wire_resource_id, Some(169193));
|
||||
// The listing carries the FULL shaped card snapshot, not a 4-field stub:
|
||||
// a stub leaves the Transfer List with an unrenderable row (the live bug).
|
||||
let snap: Value = serde_json::from_str(listed.item_json.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(snap["rating"], 84);
|
||||
assert_eq!(snap["preferredPosition"], "ST");
|
||||
assert_eq!(snap["attributeList"].as_array().unwrap().len(), 6);
|
||||
// tradePile embeds that full card and stamps the seller-pile state.
|
||||
let pile = handle_market_query("active", &econ, &store).await;
|
||||
let rec = parse(&pile)["auctionInfo"][0].clone();
|
||||
assert_eq!(rec["itemData"]["itemState"], "listFS");
|
||||
assert_eq!(rec["itemData"]["rating"], 84);
|
||||
assert_eq!(rec["itemData"]["id"], 100004617i64);
|
||||
assert_eq!(rec["itemData"]["resourceId"], 169193);
|
||||
let browse = handle_market_list("GET", None, &econ, &store).await;
|
||||
let b = parse(&browse);
|
||||
assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(b["credits"], 10_000);
|
||||
@@ -593,27 +912,31 @@ mod tests {
|
||||
async fn list_put_is_ack() {
|
||||
let (store, _d) = store_at("put").await;
|
||||
let econ = CountingEconomy::with_balance(0);
|
||||
let resp = handle_market_list("PUT", b"", &econ, &store, &AllowAllResolver).await;
|
||||
let resp = handle_market_list("PUT", None, &econ, &store).await;
|
||||
assert_eq!(resp.status, 200);
|
||||
assert_eq!(parse(&resp), json!({}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_unknown_resource_fails_closed_no_listing() {
|
||||
// An unmappable wire resourceId must NOT create a listing (a synthetic buy
|
||||
// would otherwise mint a card id Core cannot resolve). Acks neutrally.
|
||||
async fn list_unresolved_item_fails_closed_no_listing() {
|
||||
// A wire id that does not resolve to an owned card must NOT create a listing
|
||||
// (a synthetic buy would otherwise mint a card Core cannot resolve). Acks.
|
||||
let (store, _d) = store_at("deny").await;
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
let body = json!({ "itemData": { "id": 100004617, "resourceId": 424242 },
|
||||
let body = json!({ "itemData": { "id": 100004617 },
|
||||
"startingBid": 300, "buyNowPrice": 2500 });
|
||||
let resp = handle_market_list(
|
||||
"POST",
|
||||
let reverse = MapResolver::new(&[]); // maps nothing -> unresolved
|
||||
let items = FakeItems(HashMap::new());
|
||||
let ident = FixedIdentity(0);
|
||||
let resolved = resolve_market_list(
|
||||
body.to_string().as_bytes(),
|
||||
&econ,
|
||||
&store,
|
||||
&DenyAllResolver,
|
||||
)
|
||||
.await;
|
||||
&reverse,
|
||||
&ident,
|
||||
&items,
|
||||
&NoEntities,
|
||||
);
|
||||
assert!(resolved.is_none(), "unresolved item must not build a listing");
|
||||
let resp = handle_market_list("POST", resolved, &econ, &store).await;
|
||||
assert_eq!(resp.status, 200);
|
||||
assert_eq!(parse(&resp)["id"].as_i64().unwrap(), TRADE_ID_BASE);
|
||||
// Nothing persisted at the would-be trade id: not buyable.
|
||||
@@ -634,23 +957,23 @@ mod tests {
|
||||
{
|
||||
let store = MarketStore::open(db.path()).await.unwrap();
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
// A resolver that maps resourceId 20801 -> Core card_id "card_pl_042".
|
||||
struct FixedResolver;
|
||||
impl MarketCardResolver for FixedResolver {
|
||||
fn card_id_for_resource(&self, resource_id: i64) -> Option<String> {
|
||||
(resource_id == 20801).then(|| "card_pl_042".to_string())
|
||||
}
|
||||
}
|
||||
let body = json!({ "itemData": { "id": 100004900, "resourceId": 20801 },
|
||||
let body = json!({ "itemData": { "id": 100004900 },
|
||||
"startingBid": 300, "buyNowPrice": 2500 });
|
||||
let resp = handle_market_list(
|
||||
"POST",
|
||||
let reverse = MapResolver::new(&[(100004900, "core-9")]);
|
||||
let items = FakeItems(
|
||||
[("core-9".to_string(), owned("core-9", "card_pl_042"))]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
);
|
||||
let ident = FixedIdentity(20801);
|
||||
let resolved = resolve_market_list(
|
||||
body.to_string().as_bytes(),
|
||||
&econ,
|
||||
&store,
|
||||
&FixedResolver,
|
||||
)
|
||||
.await;
|
||||
&reverse,
|
||||
&ident,
|
||||
&items,
|
||||
&NoEntities,
|
||||
);
|
||||
let resp = handle_market_list("POST", resolved, &econ, &store).await;
|
||||
trade_id = parse(&resp)["id"].as_i64().unwrap();
|
||||
}
|
||||
// Reopen from the same file: both identities survive.
|
||||
@@ -676,6 +999,166 @@ mod tests {
|
||||
assert_eq!(b["credits"], 50);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn counts_is_the_five_int_tally_not_the_listing_body() {
|
||||
// FutGetAuctionCount is a DISTINCT deserializer from /tradePile: five
|
||||
// scalar ints, no auctionInfo. Answering it with the listing body leaves
|
||||
// every count at its constructor default, so the Transfer List screen
|
||||
// shows no active sale even while the hub tile reports one (live bug).
|
||||
let (store, _d) = store_at("counts").await;
|
||||
let b = parse(&handle_market_counts(&store).await);
|
||||
assert_eq!(b["count"], 0);
|
||||
assert_eq!(b["selling"], 0);
|
||||
|
||||
seed_listing(&store, "900000007", 2500).await;
|
||||
let resp = handle_market_counts(&store).await;
|
||||
assert_eq!(resp.status, 200);
|
||||
let b = parse(&resp);
|
||||
assert_eq!(b["count"], 1, "tally counts the active listing");
|
||||
assert_eq!(b["selling"], 1);
|
||||
assert_eq!(b["sold"], 0);
|
||||
assert_eq!(b["offered"], 0);
|
||||
assert_eq!(
|
||||
b["maxAuctionsAllowed"], 100,
|
||||
"cap stays open for IS_MAX_AUCTIONS"
|
||||
);
|
||||
assert!(
|
||||
b.get("auctionInfo").is_none(),
|
||||
"the tally must NOT carry the listing body"
|
||||
);
|
||||
for k in ["count", "maxAuctionsAllowed", "offered", "selling", "sold"] {
|
||||
assert!(b[k].is_i64(), "{k} must be a scalar int");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auction_record_carries_exactly_the_twelve_fields_fifa17_reads() {
|
||||
// FIFA 17's auctionInfo deserializer (0x18013e410) reads TWELVE atoms and
|
||||
// value-SKIPs everything else. Emitting extras is not harmless richness: it
|
||||
// misleads the next reader about what the client consumes, and it is how
|
||||
// `tradeOwner`/`sellerId`/`offers` got added on library hearsay and then had
|
||||
// to be removed. Pin the set.
|
||||
let (store, _d) = store_at("atoms").await;
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
seed_listing(&store, "900000030", 2500).await;
|
||||
|
||||
let body = parse(&handle_market_query("active", &econ, &store).await);
|
||||
let rec = body["auctionInfo"][0].clone();
|
||||
let mut got: Vec<&str> = rec.as_object().unwrap().keys().map(String::as_str).collect();
|
||||
got.sort_unstable();
|
||||
assert_eq!(
|
||||
got,
|
||||
[
|
||||
"bidState",
|
||||
"buyNowPrice",
|
||||
"coinsProcessed",
|
||||
"currentBid",
|
||||
"expires",
|
||||
"itemData",
|
||||
"sellerEstablished",
|
||||
"sellerName",
|
||||
"startingBid",
|
||||
"tradeId",
|
||||
"tradeState",
|
||||
"watched",
|
||||
],
|
||||
"auctionInfo must be exactly FIFA 17's twelve atoms"
|
||||
);
|
||||
// "listed by user" is active / none / currentBid 0 / expires > 0. Both
|
||||
// vocabularies are closed sets; an unrecognised bidState is swallowed as
|
||||
// `none` and renders a plausible but wrong UI.
|
||||
assert_eq!(rec["tradeState"], "active");
|
||||
assert_eq!(rec["bidState"], "none");
|
||||
assert_eq!(rec["currentBid"], 0);
|
||||
assert!(rec["expires"].as_i64().unwrap() > 0);
|
||||
assert_eq!(rec["sellerName"], non_economy::PERSONA_DISPLAY_NAME);
|
||||
|
||||
// GetTradePile shares the IS-list body: four members, including the
|
||||
// `duplicateItemIdList` we used to omit.
|
||||
let mut env: Vec<&str> = body.as_object().unwrap().keys().map(String::as_str).collect();
|
||||
env.sort_unstable();
|
||||
assert_eq!(
|
||||
env,
|
||||
["auctionInfo", "credits", "duplicateItemIdList", "total"],
|
||||
"GetTradePile envelope is the shared IS-list body"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expires_counts_down_and_an_unsold_auction_reads_expired() {
|
||||
// `expires` is SECONDS REMAINING and the client renders a live countdown
|
||||
// that it expects to reach 0. A frozen constant means the auction never
|
||||
// ages and can never run out.
|
||||
use crate::market_store::DEFAULT_DURATION_SECS;
|
||||
let (store, _d) = store_at("clock").await;
|
||||
seed_listing(&store, "900000040", 2500).await;
|
||||
let l = store.get_listing("900000040").await.unwrap();
|
||||
|
||||
let created = l.created_at.parse::<i64>().unwrap() / 1000;
|
||||
assert_eq!(
|
||||
l.expires_in_secs(created),
|
||||
DEFAULT_DURATION_SECS,
|
||||
"a fresh listing has its whole duration left"
|
||||
);
|
||||
assert_eq!(
|
||||
l.expires_in_secs(created + 600),
|
||||
DEFAULT_DURATION_SECS - 600,
|
||||
"the clock actually advances"
|
||||
);
|
||||
assert_eq!(
|
||||
l.expires_in_secs(created + DEFAULT_DURATION_SECS + 5),
|
||||
0,
|
||||
"expiry clamps at 0, never negative"
|
||||
);
|
||||
|
||||
// A closed listing has no time left regardless of when it was created.
|
||||
let mut sold = l.clone();
|
||||
sold.state = "sold".into();
|
||||
assert_eq!(sold.expires_in_secs(created), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trade_status_answers_the_poll_instead_of_an_empty_set() {
|
||||
// The Transfer List polls `…/trade/status` continuously to refresh live
|
||||
// auction state. This tail has no numeric id, so it used to fall into the
|
||||
// buy/view arm and every poll was answered with an EMPTY auctionInfo —
|
||||
// observed live, and the screen never learned its own auctions' state.
|
||||
let (store, _d) = store_at("status").await;
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
seed_listing(&store, "900000031", 2500).await;
|
||||
|
||||
// No filter: answer with the player's own active pile.
|
||||
let all = parse(&handle_market_status(None, &econ, &store).await);
|
||||
assert_eq!(
|
||||
all["auctionInfo"].as_array().unwrap().len(),
|
||||
1,
|
||||
"an unfiltered poll must not come back empty while a listing is active"
|
||||
);
|
||||
assert_eq!(all["auctionInfo"][0]["tradeId"], 900_000_031i64);
|
||||
// ISViewTrade's body is the auction list plus credits — no `total` and no
|
||||
// `duplicateItemIdList`, unlike the shared IS-list body.
|
||||
assert_eq!(all["auctionInfo"][0]["tradeState"], "active");
|
||||
assert!(all["credits"].is_i64());
|
||||
|
||||
// Explicit tradeIds filter returns exactly the requested auction.
|
||||
let one = parse(
|
||||
&handle_market_status(Some("tradeIds=900000031"), &econ, &store).await,
|
||||
);
|
||||
assert_eq!(one["auctionInfo"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(one["auctionInfo"][0]["tradeId"], 900_000_031i64);
|
||||
|
||||
// An unknown id is absent, not an error: the poll must never fail closed.
|
||||
let miss =
|
||||
parse(&handle_market_status(Some("tradeIds=900000099"), &econ, &store).await);
|
||||
assert_eq!(miss["auctionInfo"].as_array().unwrap().len(), 0);
|
||||
|
||||
// Garbage is skipped rather than poisoning the whole poll.
|
||||
assert_eq!(
|
||||
trade_ids_from_query(Some("tradeIds=900000031,abc,,900000032&x=1")),
|
||||
vec!["900000031".to_string(), "900000032".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn buy_now_debits_mints_and_closes() {
|
||||
let (store, _d) = store_at("buy").await;
|
||||
|
||||
@@ -146,6 +146,52 @@ pub struct Listing {
|
||||
pub state: String,
|
||||
/// Creation time, unix-epoch milliseconds as a string (sortable).
|
||||
pub created_at: String,
|
||||
/// The FIFA card object (`itemData`) as shaped at listing time, serialized.
|
||||
/// A listing is a SNAPSHOT: the auction record must carry the full card the
|
||||
/// client can render (rating/position/attributes/rareflag/assetId), not a
|
||||
/// stub — a stub leaves the Transfer List with an unrenderable row. `None`
|
||||
/// only for rows written before this column existed (renders as a stub).
|
||||
pub item_json: Option<String>,
|
||||
/// Listing duration in SECONDS, as sent by the client in the `ISStart` body
|
||||
/// (`duration`). With `created_at` this is the whole auction clock: FIFA 17
|
||||
/// renders a live countdown from `expires` and expects it to reach 0, so a
|
||||
/// listing has to know when it ends. `None` for rows written before this
|
||||
/// column existed, which fall back to the default duration.
|
||||
pub duration_secs: Option<i64>,
|
||||
}
|
||||
|
||||
/// FIFA 17 auction durations, in seconds: 3600, 10800, 21600, 43200, 86400,
|
||||
/// 259200. One hour is the shortest, and the fallback when a client body omits it
|
||||
/// or a pre-column row is read.
|
||||
pub const DEFAULT_DURATION_SECS: i64 = 3600;
|
||||
|
||||
/// Seconds since the unix epoch.
|
||||
pub fn now_secs() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
impl Listing {
|
||||
/// SECONDS REMAINING on this auction at `now` (unix seconds), clamped at 0 —
|
||||
/// the wire semantics of `expires`, which is never an absolute epoch.
|
||||
///
|
||||
/// A closed/sold/cancelled listing reads 0: there is no time left on an
|
||||
/// auction that has already ended.
|
||||
pub fn expires_in_secs(&self, now: i64) -> i64 {
|
||||
if self.state != "active" {
|
||||
return 0;
|
||||
}
|
||||
let created_secs = self
|
||||
.created_at
|
||||
.parse::<i64>()
|
||||
.map(|ms| ms / 1000)
|
||||
.unwrap_or(now);
|
||||
let duration = self.duration_secs.unwrap_or(DEFAULT_DURATION_SECS);
|
||||
(created_secs + duration - now).max(0)
|
||||
}
|
||||
}
|
||||
|
||||
const CREATE_LISTINGS: &str = "CREATE TABLE IF NOT EXISTS listings (
|
||||
@@ -158,7 +204,9 @@ const CREATE_LISTINGS: &str = "CREATE TABLE IF NOT EXISTS listings (
|
||||
buy_now_price INTEGER NOT NULL,
|
||||
owner TEXT,
|
||||
state TEXT NOT NULL CHECK (state IN ('active','reserved','sold','cancelled')),
|
||||
created_at TEXT NOT NULL
|
||||
created_at TEXT NOT NULL,
|
||||
item_json TEXT,
|
||||
duration_secs INTEGER
|
||||
)";
|
||||
|
||||
fn now_millis() -> String {
|
||||
@@ -182,6 +230,8 @@ fn row_to_listing(row: &sqlx::sqlite::SqliteRow) -> Listing {
|
||||
owner: row.get("owner"),
|
||||
state: row.get("state"),
|
||||
created_at: row.get("created_at"),
|
||||
item_json: row.get("item_json"),
|
||||
duration_secs: row.get("duration_secs"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +273,28 @@ impl MarketStore {
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
// Additive migration: `item_json` was added after the first stores shipped,
|
||||
// and `CREATE TABLE IF NOT EXISTS` will not add a column to an existing
|
||||
// file. Add it when absent so an existing market DB keeps working (old
|
||||
// rows read back `None` and render the stub card).
|
||||
let existing: Vec<String> = sqlx::query("PRAGMA table_info(listings)")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(db)?
|
||||
.iter()
|
||||
.map(|r| r.get::<String, _>("name"))
|
||||
.collect();
|
||||
for (col, decl) in [
|
||||
("item_json", "TEXT"),
|
||||
("duration_secs", "INTEGER"),
|
||||
] {
|
||||
if !existing.iter().any(|c| c == col) {
|
||||
sqlx::query(&format!("ALTER TABLE listings ADD COLUMN {col} {decl}"))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
}
|
||||
}
|
||||
Ok(MarketStore {
|
||||
pool,
|
||||
fault: StoreFault::default(),
|
||||
@@ -249,6 +321,11 @@ impl MarketStore {
|
||||
start_price: i64,
|
||||
buy_now_price: i64,
|
||||
owner: Option<&str>,
|
||||
// The shaped FIFA card snapshot (`itemData`) for the auction record.
|
||||
item_json: Option<&str>,
|
||||
// Listing duration in seconds from the client's `ISStart` body; `None`
|
||||
// falls back to [`DEFAULT_DURATION_SECS`].
|
||||
duration_secs: Option<i64>,
|
||||
) -> Result<Listing, MarketError> {
|
||||
let created_at = now_millis();
|
||||
let mut conn = self.pool.acquire().await.map_err(db)?;
|
||||
@@ -258,8 +335,9 @@ impl MarketStore {
|
||||
.map_err(db)?;
|
||||
let res = sqlx::query(
|
||||
"INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, \
|
||||
wire_resource_id, start_price, buy_now_price, owner, state, created_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?)",
|
||||
wire_resource_id, start_price, buy_now_price, owner, state, created_at, item_json, \
|
||||
duration_secs) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)",
|
||||
)
|
||||
.bind(listing_id)
|
||||
.bind(card_id)
|
||||
@@ -270,6 +348,8 @@ impl MarketStore {
|
||||
.bind(buy_now_price)
|
||||
.bind(owner)
|
||||
.bind(&created_at)
|
||||
.bind(item_json)
|
||||
.bind(duration_secs)
|
||||
.execute(&mut *conn)
|
||||
.await;
|
||||
match res {
|
||||
@@ -289,6 +369,8 @@ impl MarketStore {
|
||||
owner: owner.map(str::to_string),
|
||||
state: "active".to_string(),
|
||||
created_at,
|
||||
item_json: item_json.map(str::to_string),
|
||||
duration_secs,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -463,6 +545,53 @@ impl MarketStore {
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
/// RE-LIST an existing auction row for the same item: reset the clock to now
|
||||
/// and take the new prices/duration.
|
||||
///
|
||||
/// FIFA 17 relists by sending a fresh `ISStart` POST for an item that already
|
||||
/// has a listing row, so the primary-key conflict is EXPECTED and means
|
||||
/// "relist", not "error". Treating that conflict as success is how a relist
|
||||
/// silently did nothing: the client was acked while the stale, already-expired
|
||||
/// row kept its old `created_at` and stayed expired.
|
||||
///
|
||||
/// Only an `active` (including aged-out) or `cancelled` row may be relisted. A
|
||||
/// `sold` or `reserved` row is NEVER resurrected — the card is gone or in
|
||||
/// flight, and re-opening that auction would sell a card twice.
|
||||
pub async fn relist_listing(
|
||||
&self,
|
||||
listing_id: &str,
|
||||
start_price: i64,
|
||||
buy_now_price: i64,
|
||||
duration_secs: Option<i64>,
|
||||
item_json: Option<&str>,
|
||||
) -> Result<Listing, MarketError> {
|
||||
let created_at = now_millis();
|
||||
let affected = sqlx::query(
|
||||
"UPDATE listings SET state = 'active', created_at = ?, start_price = ?, \
|
||||
buy_now_price = ?, duration_secs = ?, item_json = COALESCE(?, item_json) \
|
||||
WHERE listing_id = ? AND state IN ('active', 'cancelled')",
|
||||
)
|
||||
.bind(&created_at)
|
||||
.bind(start_price)
|
||||
.bind(buy_now_price)
|
||||
.bind(duration_secs)
|
||||
.bind(item_json)
|
||||
.bind(listing_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db)?
|
||||
.rows_affected();
|
||||
if affected == 0 {
|
||||
// Either no such row, or it is sold/reserved and must not be revived.
|
||||
return Err(match self.get_listing(listing_id).await {
|
||||
Ok(l) if l.state == "sold" => MarketError::Sold,
|
||||
Ok(_) => MarketError::Conflict,
|
||||
Err(e) => e,
|
||||
});
|
||||
}
|
||||
self.get_listing(listing_id).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -494,6 +623,92 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relist_resets_the_clock_and_takes_the_new_prices() {
|
||||
// FIFA 17 relists by re-sending ISStart for an item that already has a row,
|
||||
// so the PK conflict is the relist path. Before this existed the conflict was
|
||||
// acked as success and the stale expired row kept its old created_at, so the
|
||||
// card never came back to the market.
|
||||
let (store, _d) = temp_store().await;
|
||||
let first = seed(&store, "900000001").await;
|
||||
// Age it out by rewriting created_at to well past its default duration.
|
||||
let stale = (now_secs() - DEFAULT_DURATION_SECS - 600) * 1000;
|
||||
sqlx::query("UPDATE listings SET created_at = ? WHERE listing_id = ?")
|
||||
.bind(stale.to_string())
|
||||
.bind("900000001")
|
||||
.execute(&store.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let expired = store.get_listing("900000001").await.unwrap();
|
||||
assert_eq!(
|
||||
expired.expires_in_secs(now_secs()),
|
||||
0,
|
||||
"precondition: the listing has run out"
|
||||
);
|
||||
|
||||
let relisted = store
|
||||
.relist_listing("900000001", 250, 5000, Some(10_800), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(relisted.state, "active");
|
||||
assert_eq!(relisted.start_price, 250, "new start price applied");
|
||||
assert_eq!(relisted.buy_now_price, 5000, "new buy-now applied");
|
||||
assert_eq!(relisted.duration_secs, Some(10_800));
|
||||
assert!(
|
||||
relisted.expires_in_secs(now_secs()) > 0,
|
||||
"the clock actually restarted"
|
||||
);
|
||||
assert_ne!(
|
||||
relisted.created_at, expired.created_at,
|
||||
"created_at moved forward"
|
||||
);
|
||||
// The snapshot is preserved when the relist does not supply a new one.
|
||||
assert_eq!(relisted.item_json, first.item_json);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relist_never_revives_a_sold_or_reserved_auction() {
|
||||
// Re-opening a sold auction would sell the same card twice.
|
||||
let (store, _d) = temp_store().await;
|
||||
seed(&store, "900000001").await;
|
||||
assert!(store.reserve_listing("900000001").await.unwrap());
|
||||
assert!(
|
||||
matches!(
|
||||
store.relist_listing("900000001", 1, 2, None, None).await,
|
||||
Err(MarketError::Conflict)
|
||||
),
|
||||
"a reserved (in-flight) auction is not relistable"
|
||||
);
|
||||
store.complete_sale("900000001").await.unwrap();
|
||||
assert!(
|
||||
matches!(
|
||||
store.relist_listing("900000001", 1, 2, None, None).await,
|
||||
Err(MarketError::Sold)
|
||||
),
|
||||
"a sold auction is never resurrected"
|
||||
);
|
||||
assert_eq!(store.get_listing("900000001").await.unwrap().state, "sold");
|
||||
|
||||
// A cancelled listing IS relistable (the card came back to the pile).
|
||||
seed(&store, "900000002").await;
|
||||
store.cancel_listing("900000002", None).await.unwrap();
|
||||
let back = store
|
||||
.relist_listing("900000002", 300, 900, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(back.state, "active");
|
||||
assert_eq!(back.start_price, 300);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relist_of_a_missing_row_is_not_found() {
|
||||
let (store, _d) = temp_store().await;
|
||||
assert!(matches!(
|
||||
store.relist_listing("900000999", 1, 2, None, None).await,
|
||||
Err(MarketError::NotFound)
|
||||
));
|
||||
}
|
||||
|
||||
async fn temp_store() -> (MarketStore, TempDb) {
|
||||
let db = TempDb::new();
|
||||
let store = MarketStore::open(db.path()).await.unwrap();
|
||||
@@ -502,7 +717,7 @@ mod tests {
|
||||
|
||||
async fn seed(store: &MarketStore, id: &str) -> Listing {
|
||||
store
|
||||
.create_listing(id, "card_pl_001", None, None, None, 900, 2500, None)
|
||||
.create_listing(id, "card_pl_001", None, None, None, 900, 2500, None, None, None)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
@@ -533,7 +748,7 @@ mod tests {
|
||||
seed(&store, "900000001").await;
|
||||
assert!(matches!(
|
||||
store
|
||||
.create_listing("900000001", "card_pl_002", None, None, None, 1, 2, None)
|
||||
.create_listing("900000001", "card_pl_002", None, None, None, 1, 2, None, None, None)
|
||||
.await,
|
||||
Err(MarketError::Conflict)
|
||||
));
|
||||
@@ -597,6 +812,8 @@ mod tests {
|
||||
900,
|
||||
2500,
|
||||
Some("alice"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -668,6 +885,8 @@ mod tests {
|
||||
900,
|
||||
2500,
|
||||
Some("alice"),
|
||||
Some(r#"{"rating":84}"#),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -683,5 +902,10 @@ mod tests {
|
||||
assert_eq!(got.core_item_id.as_deref(), Some("core-7"));
|
||||
assert_eq!(got.wire_item_id, Some(100004617));
|
||||
assert_eq!(got.owner.as_deref(), Some("alice"));
|
||||
assert_eq!(
|
||||
got.item_json.as_deref(),
|
||||
Some(r#"{"rating":84}"#),
|
||||
"card snapshot survives reopen"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,19 +423,18 @@ fn case_c_dup_quicksell(h: &Harness) -> String {
|
||||
/// exactly one debit; exactly one mint.
|
||||
fn case_d_two_market_buyers(h: &Harness) -> String {
|
||||
let mut wins = 0u32;
|
||||
for i in 0..ITERS {
|
||||
for _ in 0..ITERS {
|
||||
// List a genuinely-owned card: the server resolves the Core card_id +
|
||||
// resourceId from inventory via the wire id (you can only list what you own).
|
||||
let (item_id, _core) = mint_one(h);
|
||||
set_balance(&h.client, 50_000);
|
||||
let item_id = 500_000 + i as i64; // unique listing per iteration
|
||||
let list = h
|
||||
.server
|
||||
.try_handle_economy(
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
&[],
|
||||
format!(
|
||||
r#"{{"itemData":{{"id":{item_id},"resourceId":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
|
||||
h.sample_resource
|
||||
)
|
||||
format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
|
||||
.as_bytes(),
|
||||
None,
|
||||
)
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog;
|
||||
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
||||
use openfut_adapter_fifa17::fut::store_session::{SessionStore, StoreMode, SENTINEL_PACK_ID};
|
||||
use openfut_adapter_fifa17::fut::non_economy::PERSONA_DISPLAY_NAME;
|
||||
use openfut_identity::JsonIdentityStore;
|
||||
use openfut_utas_host::async_bridge::AsyncBridge;
|
||||
use openfut_utas_host::market_store::MarketStore;
|
||||
@@ -407,7 +408,7 @@ fn pack_ids(pg: &Value) -> Vec<u64> {
|
||||
fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
wait_ready(core_base);
|
||||
let http = reqwest::blocking::Client::new();
|
||||
let (server, client, sample_resource) = build_econ_server(core_base, dir);
|
||||
let (server, client, _sample_resource) = build_econ_server(core_base, dir);
|
||||
|
||||
// ── Fixture alignment: both sides own exactly one pack-70 entitlement. ──
|
||||
// Oracle: fresh profile already owns pack 70. Core: grant the "70" entitlement
|
||||
@@ -847,7 +848,20 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
None,
|
||||
);
|
||||
let o_trade_id = o_list["id"].as_i64().expect("oracle trade id");
|
||||
let (r_ls, r_list) = rust(&server, "POST", "/ut/game/fifa17/auctionhouse", format!(r#"{{"itemData":{{"id":777,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"#).as_bytes(), None);
|
||||
// Same body shape as the oracle: the wire id ALONE (the server resolves the
|
||||
// owned card's card_id + resourceId from inventory). `r_wire[1]` is the card
|
||||
// moved to the trade pile in OP 9 — the Rust parallel of the oracle's o_wire[1].
|
||||
let (r_ls, r_list) = rust(
|
||||
&server,
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
format!(
|
||||
r#"{{"itemData":{{"id":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
|
||||
r_wire[1]
|
||||
)
|
||||
.as_bytes(),
|
||||
None,
|
||||
);
|
||||
let r_trade_id = r_list["id"].as_i64().expect("rust trade id");
|
||||
assert_eq!(o_ls, 200);
|
||||
assert_eq!(r_ls, 200, "market list status parity");
|
||||
@@ -878,6 +892,64 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
r_tp["auctionInfo"][0]["tradeState"], "active",
|
||||
"rust listing active"
|
||||
);
|
||||
// Compare the record FIELD-FOR-FIELD, not merely its length and trade state.
|
||||
// The client reads the seller identity to decide whether a transfer-pile row is
|
||||
// the player's OWN — and therefore whether Remove / Re-list exist at all — and
|
||||
// a len+tradeState check is blind to that. A real client silently offered NO
|
||||
// action on the player's own listing (pressing it opened no dialog) because we
|
||||
// stamped EA's house name as the seller while the oracle stamps the persona.
|
||||
let o_rec = &o_tp["auctionInfo"][0];
|
||||
let r_rec = &r_tp["auctionInfo"][0];
|
||||
let keys = |v: &Value| {
|
||||
let mut k: Vec<String> = v
|
||||
.as_object()
|
||||
.expect("auction record is an object")
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect();
|
||||
k.sort();
|
||||
k
|
||||
};
|
||||
// Both sides emit exactly FIFA 17's twelve auctionInfo atoms, so this is a
|
||||
// strict key-set equality. NOTE the limit of that: parity here proves we match
|
||||
// the oracle, NOT that either side is complete -- a field absent from BOTH is
|
||||
// invisible to this check. That is exactly how the Transfer List Actions-panel
|
||||
// bug hid, and the client binary's atom table is the authority that settled it
|
||||
// (see docs/FIFA17_TRANSFER_MARKET_WIRE.md).
|
||||
assert_eq!(
|
||||
keys(o_rec),
|
||||
keys(r_rec),
|
||||
"tradePile auction-record key set parity"
|
||||
);
|
||||
for f in [
|
||||
"sellerName",
|
||||
"bidState",
|
||||
"currentBid",
|
||||
"sellerEstablished",
|
||||
"watched",
|
||||
"coinsProcessed",
|
||||
] {
|
||||
assert_eq!(o_rec[f], r_rec[f], "tradePile record field `{f}` parity");
|
||||
}
|
||||
assert_eq!(
|
||||
r_rec["sellerName"], PERSONA_DISPLAY_NAME,
|
||||
"the player's own listing is sold BY the player, never by EA"
|
||||
);
|
||||
// `expires` is seconds remaining on a live clock, so it need not equal the
|
||||
// oracle's constant; it must be a positive 64-bit count for an active auction.
|
||||
assert!(
|
||||
r_rec["expires"].as_i64().is_some_and(|e| e > 0),
|
||||
"an active auction has positive seconds remaining"
|
||||
);
|
||||
// itemData must be the full shaped card on both sides; a stub cannot render.
|
||||
assert_eq!(
|
||||
o_rec["itemData"]["itemState"], r_rec["itemData"]["itemState"],
|
||||
"own-pile itemState parity (listFS)"
|
||||
);
|
||||
assert!(
|
||||
r_rec["itemData"]["rating"].is_i64() && r_rec["itemData"]["attributeList"].is_array(),
|
||||
"rust tradePile itemData is the full card, not a stub"
|
||||
);
|
||||
matrix.push(("market query tradePile", "PARITY"));
|
||||
|
||||
// ── OP 13: market buy (POST /trade/<id>) — DIFFERENT-BY-DESIGN ─────────
|
||||
@@ -992,7 +1064,19 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
"oracle cancelled listing gone from tradePile"
|
||||
);
|
||||
// Rust: list a fresh item, cancel it, then a buy is a 0-delta empty auction.
|
||||
let clist = rust(&server, "POST", "/ut/game/fifa17/auctionhouse", format!(r#"{{"itemData":{{"id":888,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"#).as_bytes(), None).1;
|
||||
// A still-owned card (r_wire[0]/[3] were quick-sold, [1] is listed above).
|
||||
let clist = rust(
|
||||
&server,
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
format!(
|
||||
r#"{{"itemData":{{"id":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
|
||||
r_wire[2]
|
||||
)
|
||||
.as_bytes(),
|
||||
None,
|
||||
)
|
||||
.1;
|
||||
let r_cancel_id = clist["id"].as_i64().unwrap();
|
||||
let (r_cs, _) = rust(
|
||||
&server,
|
||||
|
||||
@@ -257,7 +257,6 @@ struct FailHarness {
|
||||
bridge: Arc<AsyncBridge>,
|
||||
core: Arc<dyn CoreAccess>,
|
||||
entities: Arc<Fifa17Entities>,
|
||||
sample_resource: i64,
|
||||
}
|
||||
|
||||
fn catalog_from_core(core: &dyn CoreAccess) -> Fifa17CardCatalog {
|
||||
@@ -341,7 +340,6 @@ fn build_fail_harness(base: &str, dir: &std::path::Path) -> FailHarness {
|
||||
bridge,
|
||||
core,
|
||||
entities,
|
||||
sample_resource: 20000,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -676,15 +674,14 @@ fn case_move_pile_failure(h: &FailHarness) -> String {
|
||||
|
||||
/// MARKET RESERVE failure → no debit, no grant, listing stays legal (active).
|
||||
fn case_market_reserve_failure(h: &FailHarness) -> String {
|
||||
// List a genuinely-owned card: the server resolves card_id + resourceId from
|
||||
// Core inventory via the wire id (you can only list what you own).
|
||||
let (item_id, _core) = h.mint_one();
|
||||
set_balance(&h.client, 50_000);
|
||||
let item_id = 700_001i64;
|
||||
let list = h.dispatch(
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
format!(
|
||||
r#"{{"itemData":{{"id":{item_id},"resourceId":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
|
||||
h.sample_resource
|
||||
)
|
||||
format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
|
||||
.as_bytes(),
|
||||
);
|
||||
let trade_id = bj(&list)["id"].as_i64().expect("trade id");
|
||||
@@ -712,15 +709,12 @@ fn case_market_reserve_failure(h: &FailHarness) -> String {
|
||||
/// MARKET Core purchase_item failure AFTER reserve → reservation rolls back to
|
||||
/// active, no debit, no mint.
|
||||
fn case_market_purchase_failure(h: &FailHarness) -> String {
|
||||
let (item_id, _core) = h.mint_one();
|
||||
set_balance(&h.client, 50_000);
|
||||
let item_id = 700_002i64;
|
||||
let list = h.dispatch(
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
format!(
|
||||
r#"{{"itemData":{{"id":{item_id},"resourceId":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
|
||||
h.sample_resource
|
||||
)
|
||||
format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
|
||||
.as_bytes(),
|
||||
);
|
||||
let trade_id = bj(&list)["id"].as_i64().expect("trade id");
|
||||
@@ -754,15 +748,12 @@ fn case_market_purchase_failure(h: &FailHarness) -> String {
|
||||
/// active), so no further `active -> reserved` CAS can succeed → not buyable,
|
||||
/// with exactly one debit + one mint. Returns ("SAFE"|"E3", detail).
|
||||
fn case_market_complete_sale_failure(h: &FailHarness) -> (String, String) {
|
||||
let (item_id, _core) = h.mint_one();
|
||||
set_balance(&h.client, 50_000);
|
||||
let item_id = 700_003i64;
|
||||
let list = h.dispatch(
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
format!(
|
||||
r#"{{"itemData":{{"id":{item_id},"resourceId":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
|
||||
h.sample_resource
|
||||
)
|
||||
format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
|
||||
.as_bytes(),
|
||||
);
|
||||
let trade_id = bj(&list)["id"].as_i64().expect("trade id");
|
||||
|
||||
@@ -350,7 +350,7 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
||||
wait_ready(base);
|
||||
// Core is seeded (start_core_seeded): a fifa17 profile with 100k coins + one
|
||||
// owned instance per definition. No /auth/local — the profile already exists.
|
||||
let (server, client, resolver, sample_resource) =
|
||||
let (server, client, resolver, _sample_resource) =
|
||||
build_econ_server(base, dir, "http://127.0.0.1:9");
|
||||
let start = client.balance().unwrap();
|
||||
assert!(start >= 5000, "seeded dev balance present ({start})");
|
||||
@@ -440,13 +440,17 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
||||
|
||||
// 5) MARKET buy-now (async handlers via the bridge): list -> query -> buy ->
|
||||
// query -> second buy fails, exactly one debit + one sale.
|
||||
// List a still-owned minted card (items[0] was quick-sold, items[1] is moved
|
||||
// below). The body carries the wire id ALONE — the server resolves the owned
|
||||
// card's Core card_id + FIFA resourceId from inventory.
|
||||
let list_wire = items[2]["id"].as_i64().unwrap();
|
||||
let list = server
|
||||
.try_handle_economy(
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
&[],
|
||||
format!(
|
||||
r#"{{"itemData":{{"id":777,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"#
|
||||
r#"{{"itemData":{{"id":{list_wire}}},"buyNowPrice":1000,"startingBid":500}}"#
|
||||
)
|
||||
.as_bytes(),
|
||||
None,
|
||||
@@ -504,13 +508,14 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
||||
);
|
||||
|
||||
// 6) MARKET cancel: a cancelled listing cannot be bought.
|
||||
let cancel_wire = items[3]["id"].as_i64().unwrap();
|
||||
let clist = server
|
||||
.try_handle_economy(
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
&[],
|
||||
format!(
|
||||
r#"{{"itemData":{{"id":888,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"#
|
||||
r#"{{"itemData":{{"id":{cancel_wire}}},"buyNowPrice":1000,"startingBid":500}}"#
|
||||
)
|
||||
.as_bytes(),
|
||||
None,
|
||||
@@ -759,6 +764,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -795,6 +801,11 @@ fn exercise_from_config(base: &str, dir: &std::path::Path) -> i64 {
|
||||
)
|
||||
.expect("buy routed");
|
||||
assert_eq!(buy.status, 200);
|
||||
// A listing must name a card the club actually owns, so take one the BUY minted.
|
||||
let list_wire = serde_json::from_slice::<Value>(&buy.body).unwrap()["createPackResponse"]
|
||||
["itemList"][0]["id"]
|
||||
.as_i64()
|
||||
.expect("minted wire id");
|
||||
assert_eq!(
|
||||
client.balance().unwrap(),
|
||||
start - 400,
|
||||
@@ -807,7 +818,8 @@ fn exercise_from_config(base: &str, dir: &std::path::Path) -> i64 {
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
&[],
|
||||
br#"{"itemData":{"id":555,"resourceId":20000},"buyNowPrice":1000,"startingBid":500}"#,
|
||||
format!(r#"{{"itemData":{{"id":{list_wire}}},"buyNowPrice":1000,"startingBid":500}}"#)
|
||||
.as_bytes(),
|
||||
None,
|
||||
)
|
||||
.expect("list routed");
|
||||
|
||||
@@ -13,10 +13,10 @@ use openfut_adapter_fifa17::fut::club_response::{CoreOwnedItem, ItemIdentityReso
|
||||
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
||||
use openfut_identity::JsonIdentityStore;
|
||||
use openfut_utas_host::{
|
||||
classify, handle_put_squad, handle_squad_active, handle_squad_list, handle_user_mass_info,
|
||||
read_request, CoreAccess, CoreError, CoreExtState, CorePage, CoreReplaceRequest,
|
||||
CoreReplaceResult, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient,
|
||||
PassClient, Route, Server, SquadDeps,
|
||||
classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list,
|
||||
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState, CorePage,
|
||||
CoreReplaceRequest, CoreReplaceResult, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver,
|
||||
HttpCoreClient, PassClient, Route, Server, SquadDeps,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::Value;
|
||||
@@ -279,6 +279,86 @@ fn build_server(
|
||||
)
|
||||
}
|
||||
|
||||
/// A real identity resolver over a `card_id -> asset_id` map (same construction
|
||||
/// `build_server` uses), for tests that call `handle_club` directly.
|
||||
fn resolver_for(cards: &[(&str, u32)]) -> Arc<Fifa17IdentityResolver> {
|
||||
let entries: Vec<String> = cards
|
||||
.iter()
|
||||
.map(|(id, asset)| format!("\"{id}\":{{\"asset_id\":{asset}}}"))
|
||||
.collect();
|
||||
let doc = format!(
|
||||
"{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{}}}}}",
|
||||
entries.join(",")
|
||||
);
|
||||
let catalog = Fifa17CardCatalog::from_json_str(&doc).unwrap();
|
||||
let store = JsonIdentityStore::open(unique_store_path()).unwrap();
|
||||
Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)))
|
||||
}
|
||||
|
||||
/// A card with an ACTIVE listing has LEFT the club: `/club` must not show it, and
|
||||
/// pagination must run over the CLUB-VISIBLE set (never Core's unfiltered page,
|
||||
/// which would hand back short pages).
|
||||
#[test]
|
||||
fn club_excludes_listed_items_and_paginates_the_visible_set() {
|
||||
let core = Arc::new(FakeCore::new(
|
||||
vec![
|
||||
item("oc1", "card_a", 86, "CDM", "Argentina", "Premier League", "Chelsea"),
|
||||
item("oc2", "card_b", 85, "ST", "Argentina", "Premier League", "Chelsea"),
|
||||
item("oc3", "card_c", 84, "CB", "Argentina", "Premier League", "Chelsea"),
|
||||
],
|
||||
3,
|
||||
));
|
||||
let resolver = resolver_for(&[("card_a", 20801), ("card_b", 20802), ("card_c", 20803)]);
|
||||
let ents = entities();
|
||||
|
||||
// oc2 has an active transfer-market listing.
|
||||
let hidden: std::collections::HashSet<String> = ["oc2".to_string()].into_iter().collect();
|
||||
let deps = ClubDeps {
|
||||
core: core.as_ref(),
|
||||
entities: &ents,
|
||||
assets: resolver.as_ref(),
|
||||
hidden: &hidden,
|
||||
};
|
||||
let (resp, log) = handle_club("", &deps);
|
||||
let v: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
let assets: Vec<i64> = v["itemData"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|i| i["assetId"].as_i64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
assets,
|
||||
vec![20801, 20803],
|
||||
"the transfer-pile card is not in the club"
|
||||
);
|
||||
assert_eq!(log.total, 2, "total is the club-visible count");
|
||||
|
||||
// A page of 2 over a 2-item visible set is FULL — not short because a hidden
|
||||
// item consumed a slot.
|
||||
let (resp2, log2) = handle_club("count=2", &deps);
|
||||
let v2: Value = serde_json::from_slice(&resp2.body).unwrap();
|
||||
assert_eq!(
|
||||
v2["itemData"].as_array().unwrap().len(),
|
||||
2,
|
||||
"full-width page from the visible set"
|
||||
);
|
||||
assert_eq!(log2.total, 2);
|
||||
|
||||
// Nothing hidden → the fast Core-paginated path, all three visible.
|
||||
let none: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let deps_all = ClubDeps {
|
||||
core: core.as_ref(),
|
||||
entities: &ents,
|
||||
assets: resolver.as_ref(),
|
||||
hidden: &none,
|
||||
};
|
||||
let (resp3, log3) = handle_club("", &deps_all);
|
||||
let v3: Value = serde_json::from_slice(&resp3.body).unwrap();
|
||||
assert_eq!(v3["itemData"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(log3.total, 3);
|
||||
}
|
||||
|
||||
// ── /club served from Core ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -795,7 +875,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 +887,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!(
|
||||
@@ -1347,3 +1432,127 @@ fn duplicate_definition_instances_stay_distinct_through_host() {
|
||||
assert_eq!(p["itemData"]["resourceId"], 20801, "shared asset id");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Non-economy routes owned by Rust (no Python fallback, no Core) ──────────────
|
||||
|
||||
/// The migrated non-economy static routes are served entirely by Rust: exact
|
||||
/// oracle-matching bodies, never proxied to Python, never touching Core. The
|
||||
/// security-question route fails closed (400) for an unknown session in Rust —
|
||||
/// again without any Python fallback. This is the per-domain no-fallback proof.
|
||||
#[test]
|
||||
fn non_economy_routes_rust_owned_no_python_no_core() {
|
||||
let core = Arc::new(FakeCore::forbidden()); // any Core call panics
|
||||
let (py_url, rec) = spawn_mock_python();
|
||||
let server = build_server(core, &py_url, None);
|
||||
|
||||
let cases: &[(&str, &str, serde_json::Value)] = &[
|
||||
(
|
||||
"GET",
|
||||
"/ut/game/fifa17/user/accountinfo",
|
||||
serde_json::json!({}),
|
||||
),
|
||||
(
|
||||
"GET",
|
||||
"/ut/game/fifa17/settings",
|
||||
serde_json::json!({ "configs": [] }),
|
||||
),
|
||||
(
|
||||
"GET",
|
||||
"/ut/game/fifa17/leaderboards/options",
|
||||
serde_json::json!({}),
|
||||
),
|
||||
("PUT", "/ut/game/fifa17/match/reset", serde_json::json!({})),
|
||||
(
|
||||
"GET",
|
||||
"/ut/game/fifa17/club/stats/staff",
|
||||
serde_json::json!({}),
|
||||
),
|
||||
];
|
||||
for (m, p, want) in cases {
|
||||
let resp = server.handle(m, p, &[], b"");
|
||||
assert_eq!(resp.status, 200, "{m} {p} status");
|
||||
let body: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
assert_eq!(&body, want, "{m} {p} body");
|
||||
}
|
||||
|
||||
// Security-question with an unknown session fails closed in Rust (never proxied).
|
||||
let resp = server.handle(
|
||||
"GET",
|
||||
"/ut/game/fifa17/phishing/trusteddevice?deviceId=6236375476659cd0f6c780e728774b71",
|
||||
&[("X-UT-SID".into(), "unknown-sid".into())],
|
||||
b"",
|
||||
);
|
||||
assert_eq!(resp.status, 400);
|
||||
let body: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
assert_eq!(body, serde_json::json!({ "reason": "invalid_session" }));
|
||||
|
||||
// None of the migrated routes reached the Python upstream.
|
||||
assert_eq!(
|
||||
rec.lock().len(),
|
||||
0,
|
||||
"no Python fallback for migrated non-economy routes"
|
||||
);
|
||||
}
|
||||
|
||||
/// `GET /hub` is served from Rust: `clubPlayers` counts owned PLAYER cards in
|
||||
/// Core, auction/tradePile counts come from the durable market store (0 with no
|
||||
/// economy wired), and Python is never consulted.
|
||||
#[test]
|
||||
fn hub_counts_players_from_core_no_python() {
|
||||
let items = vec![
|
||||
item("oc1", "card_a", 84, "ST", "Brazil", "La Liga", "Barcelona"),
|
||||
item("oc2", "card_b", 80, "CM", "Spain", "La Liga", "Real Madrid"),
|
||||
item("oc3", "card_c", 77, "CB", "France", "Ligue 1", "PSG"),
|
||||
];
|
||||
let core = Arc::new(FakeCore::new(items, 3));
|
||||
let (py_url, rec) = spawn_mock_python();
|
||||
let server = build_server(core, &py_url, None);
|
||||
|
||||
let resp = server.handle("GET", "/ut/game/fifa17/hub", &[], b"");
|
||||
assert_eq!(resp.status, 200);
|
||||
let body: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
assert_eq!(body["clubPlayers"], 3, "counts owned player cards");
|
||||
assert_eq!(body["auctionCount"], 0, "no economy wired => 0 listings");
|
||||
assert_eq!(
|
||||
body["tradePile"],
|
||||
serde_json::json!({ "count": 0, "selling": 0, "sold": 0 })
|
||||
);
|
||||
assert_eq!(rec.lock().len(), 0, "hub never reaches Python");
|
||||
}
|
||||
|
||||
/// `GET /club/stats/year` is served from Rust with Core-accurate player tier
|
||||
/// counts (contextId 1 global bucket), never reaching Python.
|
||||
#[test]
|
||||
fn club_stats_year_counts_tiers_from_core_no_python() {
|
||||
let items = vec![
|
||||
item("oc1", "card_a", 90, "ST", "Brazil", "La Liga", "Barcelona"), // gold
|
||||
item("oc2", "card_b", 70, "CM", "Spain", "La Liga", "Real Madrid"), // silver
|
||||
item("oc3", "card_c", 60, "CB", "France", "Ligue 1", "PSG"), // bronze
|
||||
];
|
||||
let core = Arc::new(FakeCore::new(items, 3));
|
||||
let (py_url, rec) = spawn_mock_python();
|
||||
let server = build_server(core, &py_url, None);
|
||||
|
||||
let resp = server.handle("GET", "/ut/game/fifa17/club/stats/year", &[], b"");
|
||||
assert_eq!(resp.status, 200);
|
||||
let body: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
let g: std::collections::HashMap<String, i64> = body["stat"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|r| r["contextId"] == 1)
|
||||
.map(|r| {
|
||||
(
|
||||
r["type"].as_str().unwrap().to_string(),
|
||||
r["typeValue"].as_i64().unwrap(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(g["players"], 3);
|
||||
assert_eq!(g["playersGold"], 1);
|
||||
assert_eq!(g["playersSilver"], 1);
|
||||
assert_eq!(g["playersBronze"], 1);
|
||||
assert_eq!(g["consumables"], 0);
|
||||
assert_eq!(g["staff"], 0);
|
||||
assert_eq!(rec.lock().len(), 0, "club/stats never reaches Python");
|
||||
}
|
||||
|
||||
Executable
+147
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OpenFUT UTAS route-reachability reporter.
|
||||
|
||||
Parses the openfut-utas-host `owner=` dispatch log (the line the host prints for
|
||||
EVERY request) into per-owner and per-Python-domain hit counts, so a staging
|
||||
preflight can assert the post-P1 invariants WITHOUT mutating anything:
|
||||
|
||||
* economy Python hits == 0 (any nonzero => P1 economy regression)
|
||||
* migrated non-economy Python hits == 0 (accountinfo/settings/leaderboards/
|
||||
match-reset/phishing are Rust-owned since post-P1)
|
||||
* remaining Python domains are exactly the documented residual set.
|
||||
|
||||
This is dev/staging tooling (Python is fine here — production authority is Rust).
|
||||
It only READS a log (file path arg, or stdin); it never touches production.
|
||||
|
||||
Log grammar (openfut-utas-host, src/lib.rs eprintln! lines), examples:
|
||||
utas-host owner=RUST route=economy method=GET path=/ut/game/fifa17/user/credits status=200
|
||||
utas-host owner=PYTHON_FALLBACK method=GET path=/ut/game/fifa17/hub status=200
|
||||
utas-host owner=RUST_OVERLAY route=userMassInfo status=200 ...
|
||||
utas-host owner=RUST_OBSERVE route=auth status=200 ...
|
||||
utas-host owner=RUST route=accountinfo status=200
|
||||
|
||||
Usage:
|
||||
openfut-reachability.py [LOGFILE] # report + gate (exit 1 on regression)
|
||||
hub logs ... | openfut-reachability.py # read from stdin
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
|
||||
OWNER_RE = re.compile(r"utas-host owner=(\S+)")
|
||||
PATH_RE = re.compile(r"path=(\S+)")
|
||||
ROUTE_RE = re.compile(r"route=(\S+)")
|
||||
|
||||
# Non-economy routes migrated to Rust ownership post-P1. A PYTHON_FALLBACK hit on
|
||||
# any of these is a MIGRATION REGRESSION (the classifier lost the route).
|
||||
MIGRATED_NON_ECONOMY = {
|
||||
"user/accountinfo",
|
||||
"settings",
|
||||
"leaderboards/options",
|
||||
"match/reset",
|
||||
"phishing", # phishing/{trusteddevice,question,validate}
|
||||
"hub",
|
||||
"club/stats/global", # year/consumables/staff
|
||||
}
|
||||
|
||||
# Documented residual Python-owned non-economy domains (expected > 0 until
|
||||
# migrated). Keep in sync with docs/PRODUCTION_AUTHORITY_MATRIX.md.
|
||||
RESIDUAL_PYTHON = {
|
||||
"openfut/account/sync",
|
||||
"club/stats/context", # country/league/team nation-bucket sub-screens
|
||||
"clientdata/userHubData",
|
||||
}
|
||||
|
||||
|
||||
def python_domain(path: str) -> str:
|
||||
"""Normalize a proxied path to its domain key."""
|
||||
p = path.split("?", 1)[0]
|
||||
if p.startswith("/openfut/account/sync"):
|
||||
return "openfut/account/sync"
|
||||
# strip /ut/game/<sku>/ or /ut/v2/game/<sku>/ prefix
|
||||
m = re.match(r"/ut/(?:v2/)?game/[^/]+/(.*)", p)
|
||||
tail = m.group(1) if m else p.lstrip("/")
|
||||
if tail.startswith("phishing/"):
|
||||
return "phishing"
|
||||
if tail.startswith("club/stats/country") or tail.startswith(
|
||||
"club/stats/league"
|
||||
) or tail.startswith("club/stats/team"):
|
||||
return "club/stats/context"
|
||||
if tail.startswith("club/stats"):
|
||||
return "club/stats/global"
|
||||
if tail.startswith("clientdata/"):
|
||||
return "clientdata/userHubData"
|
||||
return tail
|
||||
|
||||
|
||||
def is_economy(line: str, route: str | None) -> bool:
|
||||
return route == "economy"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
src = sys.stdin
|
||||
if len(sys.argv) > 1:
|
||||
src = open(sys.argv[1], encoding="utf-8", errors="replace")
|
||||
owners = Counter()
|
||||
economy_python = [] # economy routes that escaped to Python (BAD)
|
||||
migrated_regressions = [] # migrated non-economy routes that hit Python (BAD)
|
||||
python_domains = Counter()
|
||||
for line in src:
|
||||
mo = OWNER_RE.search(line)
|
||||
if not mo:
|
||||
continue
|
||||
owner = mo.group(1)
|
||||
owners[owner] += 1
|
||||
route = (ROUTE_RE.search(line) or [None, None])[1] if ROUTE_RE.search(line) else None
|
||||
if owner == "PYTHON_FALLBACK":
|
||||
pm = PATH_RE.search(line)
|
||||
path = pm.group(1) if pm else "?"
|
||||
dom = python_domain(path)
|
||||
python_domains[dom] += 1
|
||||
if dom in MIGRATED_NON_ECONOMY:
|
||||
migrated_regressions.append(path)
|
||||
# A properly classified economy route is owner=RUST route=economy. If an
|
||||
# economy tail ever shows up as PYTHON_FALLBACK that is the regression.
|
||||
if owner == "PYTHON_FALLBACK" and route is None:
|
||||
pm = PATH_RE.search(line)
|
||||
path = pm.group(1) if pm else ""
|
||||
if re.search(r"/(user/credits|store/(transaction|purchasegroup)|purchased|tradepile|"
|
||||
r"transfermarket|auctionhouse|trade/|item)", path, re.I):
|
||||
economy_python.append(path)
|
||||
|
||||
print("=== OWNER COUNTS ===")
|
||||
for owner, n in owners.most_common():
|
||||
print(f" {owner:18} {n}")
|
||||
print("=== PYTHON_FALLBACK DOMAINS ===")
|
||||
for dom, n in python_domains.most_common():
|
||||
tag = ""
|
||||
if dom in MIGRATED_NON_ECONOMY:
|
||||
tag = " <-- REGRESSION (should be Rust)"
|
||||
elif dom not in RESIDUAL_PYTHON:
|
||||
tag = " <-- UNEXPECTED (not in residual set)"
|
||||
print(f" {dom:28} {n}{tag}")
|
||||
|
||||
ok = True
|
||||
if economy_python:
|
||||
ok = False
|
||||
print("\nFAIL: economy routes reached Python (P1 REGRESSION):")
|
||||
for p in economy_python:
|
||||
print(f" {p}")
|
||||
if migrated_regressions:
|
||||
ok = False
|
||||
print("\nFAIL: migrated non-economy routes reached Python:")
|
||||
for p in migrated_regressions:
|
||||
print(f" {p}")
|
||||
unexpected = [d for d in python_domains
|
||||
if d not in RESIDUAL_PYTHON and d not in MIGRATED_NON_ECONOMY]
|
||||
if unexpected:
|
||||
print("\nWARN: undocumented Python domains (classify + add to matrix):")
|
||||
for d in unexpected:
|
||||
print(f" {d} ({python_domains[d]})")
|
||||
|
||||
print("\nGATE:", "PASS" if ok else "FAIL")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user