34 Commits

Author SHA1 Message Date
funman300 b1d7ed2570 fix(market): relisting an expired auction actually relists it
The client's relist arrives as a fresh ISStart (`POST /auctionhouse`) for an item
that ALREADY has a listing row, so `create_listing` hit a primary-key conflict. The
handler treated `Err(Conflict)` as success: it logged `listed=true`, handed the
client its trade id, and persisted nothing. The stale row kept its old `created_at`,
so the card stayed expired and the relist appeared to do nothing -- observed live,
with the client's price-limits fetch and the ISStart POST both in the log.

The PK conflict IS the relist path. `relist_listing` now resets `created_at` to now
and takes the new prices and duration, so the auction actually returns to the market
with a fresh countdown.

Refuses to revive a `sold` or `reserved` row: re-opening a sold auction would sell
the same card twice. `cancelled` rows ARE relistable (the card is back in the pile).
Missing rows report NotFound rather than silently succeeding. The failure paths still
ack so the screen cannot wedge, but they now say `relisted=false reason=...` in the
log instead of claiming success.

Three store tests: the clock/price reset, the sold+reserved revival guard (plus the
cancelled-is-relistable case), and NotFound.

336 tests pass, 0 failed, clippy clean.
2026-08-17 19:17:35 +00:00
funman300 dcbef721f2 docs+tools: measure the FIFA 17 market gate bytes in the live client
Adds trade_gate_probe.py (read-only: /proc/<pid>/mem O_RDONLY + pread, slide proven
against the on-disk FNV prologue), extending gate_byte_probe.py to vtable slot
+0x270 exactly as the transfer-market analysis asked for.

Measured: IS_TRADING_ENABLED=1 (was 0 in the Python era), TRADE_PILE_SIZE=100
(was 0), watchListSize=50 (was 0), with four controls reading 1. So every
CardsDLL-supplied input that analysis named as a market blocker is now OPEN, which
the Rust host achieves by construction -- it emits userInfo.feature as {} so the
kill switch at 0x180174f19 never arms, and it already sends pileSizeClientData
keys 2 and 4.

This narrows the Actions-panel question to the exe-side UI script term, and rules
out ownership fields, the gate bytes, the cancel route and the state vocabularies
as candidates -- each on measured or PE-derived evidence rather than inference.
2026-08-17 19:09:03 +00:00
funman300 772f8a615a fix(market): pin auctionInfo to FIFA 17's twelve atoms, add the real auction clock
Corrects the record against the CLIENT BINARY rather than library hearsay, using
the project's own reverse-engineering record
(fifa17-recon/docs/plan-2026-08-06-transfer-market.md, read out of the on-disk PE).

REVERTED (refuted): `tradeOwner`, `sellerId`, `offers`. FIFA 17's auctionInfo
deserializer (0x18013e410) reads exactly TWELVE atoms -- bidState, buyNowPrice,
currentBid, expires, itemData, sellerEstablished, sellerName, startingBid,
coinsProcessed, tradeId, tradeState, watched -- and value-SKIPs everything else at
0x180135ff0. Those three fields were added last commit on the strength of
contemporaneous FIFA 17 libraries; the PE says the client never reads them, so they
were inert and could not have been the Actions-panel gate. A preservation emulator
must not emit fields the client does not consume. New test pins the exact set.

ADDED: the auction clock. `expires` is SECONDS REMAINING (never an epoch) and the
client renders a LIVE COUNTDOWN it expects to reach 0. We hardcoded 3600, so no
auction ever aged or ran out. Now `duration` is taken from the ISStart body
(additive `duration_secs` column, defaulting to 3600) and `expires` is derived from
created_at + duration - now, clamped at 0. An active listing whose clock has run
out projects as `expired`/`none`/`expires: 0` -- FIFA 17's relistable state, per the
lifecycle table (active=1 inactive=2 expired=3 closed=4; none=0 outbid=1 highest=2
buyNow=3, both closed vocabularies). Pure projection: no row is mutated, so no
sweeper and no race with the economy.

ADDED: `duplicateItemIdList: []` on GetTradePile, which shares one deserializer
(0x18013e7f0) with ISSearch/ISWatchList over four members and we were omitting one.

CONFIRMED by the same source, so kept: `GET ut/{ns}/trade/status?tradeIds=a,b,c` is
real (ISVIEWTRADE) and my handler matches it exactly, including the comma list.
`ISREMOVETRADE` is `DELETE ut/delete/{ns}/trade/{tradeId}` -- our ORIGINAL spelling
was right. The plain-DELETE arm stays because the same source advises dispatching
on path and being method-agnostic (HTTP verbs are not statically recoverable).

Differential returns to strict key-set parity, with a comment recording WHY parity
is not sufficient: a field absent from both sides is invisible to it.

333 tests pass, 0 failed, clippy clean. Verified live: the twelve-atom record, the
four-member envelope, and the listing correctly reading expires=0 / expired after
aging past its hour.
2026-08-17 19:06:33 +00:00
funman300 bf9ae20367 docs: FIFA 17 transfer-market wire findings with confidence tags
Records the auction-record field set, route spellings, pile encoding and the four
open UNKNOWNs so future agents neither reopen settled questions nor re-guess enum
values. Each claim tagged CONFIRMED / FIFA17-HISTORICAL / INFERRED / UNKNOWN.

Captures the key methodological lesson: oracle parity is necessary but NOT
sufficient for a flow the oracle itself never served -- our auction record matched
the oracle key-for-key while both omitted the FIFA 17 ownership fields.
2026-08-17 18:51:13 +00:00
funman300 58d1f9426f fix(market): add FIFA 17 tradeOwner/sellerId, answer trade/status, route plain DELETE
Three defects behind "selecting my own Transfer List listing opens no dialog".
Pressing the card emits NO HTTP at all, so the gate is a field in what we already
return -- the client decides locally from the auction record.

1. OWNERSHIP FIELDS (FIFA17-HISTORICAL). FIFA 17 auctionInfo carries `tradeOwner`
   (bool), `sellerId` and `offers`; we emitted none of them. `tradeOwner` is the
   purpose-built "this auction is mine" flag, and without it the Transfer List has
   nothing to key owner actions (Remove / Re-list) on. `sellerId` now carries the
   configured persona so it agrees with `tradeOwner` and `sellerName` instead of
   telling three different stories. Persona is threaded from config, never baked in.

2. `GET …/trade/status` ANSWERED EMPTY (CONFIRMED from our own live logs). The
   Transfer List polls this continuously to refresh live auction state. The tail has
   no numeric id, so it fell through `t.starts_with("trade")` into the buy/view arm,
   where `trade_id_from_path` fails and the reply is `{"auctionInfo": []}`. The
   client asked for the state of its own listings and was repeatedly told there was
   none. Now a real handler: `tradeIds` filter, or the whole active pile unfiltered;
   unknown ids are absent rather than an error, so a poll never fails closed.

3. PLAIN `DELETE …/trade/<id>` WAS A SILENT NO-OP. Contemporaneous FIFA 17 clients
   cancel via `DELETE /ut/game/<sku>/trade/<id>`; only the oracle's
   `/ut/delete/game/…` spelling mapped to MarketCancel, so the plain form landed in
   the buy/view arm and "cancelled" nothing while returning 200. Both spellings now
   map to MarketCancel. Kept the oracle spelling: the differential exercises it.

Why the differential missed all of this: our record's key set was IDENTICAL to the
oracle's, so parity was green. The oracle omits the ownership fields too, because
its own remove flow was never driven by a real client either. The differential now
asserts we COVER every oracle key and that our extra keys are EXACTLY
{offers, sellerId, tradeOwner} -- so an unexplained new divergence still fails,
while the deliberate superset is pinned.

Deliberately NOT changed (no evidence): itemState stays "listFS", expires stays
3600 seconds-remaining, bidState stays "none" for active/unbid, counts stays
count=1, and no FIFA 18+ price fields were added.

332 tests pass, 0 failed, clippy clean. Deployed and verified live: tradeOwner=true
sellerId=33068179 sellerName='CAGE' offers=0 on /tradePile AND /trade/status
(filtered and unfiltered).
2026-08-17 18:50:19 +00:00
funman300 3cd31c4322 fix(market): stamp the player's persona as sellerName, not EA's house name
A card listed on the Transfer Market rendered correctly in the Transfer List but
pressing it opened NO Actions panel, so Remove / Re-list were unreachable. The one
field where our auction record diverged from the oracle was the seller: we stamped
"EASFC" while the oracle stamps the account's persona name. `fut_account.py`
annotates that very property as "Blaze PDTL.DSNM / LSX GetProfileResponse Persona /
UTAS sellerName", so EA's house name on the player's OWN listing is simply wrong,
whether or not it proves to be the gate on the Actions panel.

Introduces `non_economy::PERSONA_DISPLAY_NAME` as the single source of truth and
uses it both for the `account/sync` default (previously a bare "CAGE" literal) and
as the market seller. Every listing in this store is the player's own -- there is no
NPC seller in a single-account emulator -- so the fallback is the player.

Also strengthens the differential: it compared only auctionInfo LENGTH and
tradeState, so it was structurally blind to this. It now compares the record key
set and each shared field against the live Python oracle, asserts the seller is the
persona rather than EA, and asserts itemData is the full card rather than a stub.

That strengthened comparison passes against the real oracle subprocess, which
establishes two things: our record's key set is IDENTICAL to the oracle's (we are
missing no field relative to it), and sellerName was the only divergence.

NOTE the limit of that evidence: the oracle's own Transfer List remove flow has
never been confirmed against a real client either (the only live datapoint is a
counts-tile bug), so parity is necessary but may not be sufficient. If the client
still offers no dialog, the missing field is missing on BOTH sides and must come
from client instrumentation, not from the oracle.

14 targets green, clippy clean. Deployed and verified live: sellerName='CAGE',
listing intact, coins unchanged.
2026-08-17 18:29:19 +00:00
funman300 ae5feb05b7 docs: record the external FIFA 17 FUT hub behavioural spec + cross-check
Operator-supplied research document (authored outside this repo) describing the
player-visible FUT hub state machine. Stored verbatim so it cannot drift, with a
provenance header pinning its standing: it is a BEHAVIOUR target, never a protocol
reference. Its own §43 already forbids inventing route/field/sentinel/empty-state
details from it, which matches project policy (guessing wire spellings is the
documented client-freeze class).

Appended a repo-grounded cross-check that tags each relevant claim CONFIRMED /
CONFLICT / GAP / UNVERIFIED, so a future agent cannot mistake the aspirational
parts for observed behaviour. Notably it CONFLICTS with the recovered client
tables twice (Manager League is deliberately excluded from the consumable
overlay; there is no apply-consumable endpoint upstream at all), and it usefully
confirms that "sent to the Transfer List but not currently listed" is a real FUT
state -- which is exactly the limbo f2c4927 worked around.
2026-08-17 18:25:23 +00:00
funman300 f2c4927ea6 fix(club): hide only ACTIVELY-LISTED cards, not the whole trade pile
Keying the club exclusion on the `trade` pile put cards in limbo: the pile can
hold cards with no active listing (a bare "Place on Transfer Market" move, or a
listing later cancelled/sold), and `/tradePile` renders ONLY active listings — so
those cards were invisible in BOTH views. Live prod had 5 trade-pile rows but 1
active listing, so 4 owned cards had no reachable screen (clubPlayers 1966->1961).

Key on the ACTIVE LISTING instead (market store `core_item_id` of `state=active`).
This is self-healing: the moment a listing stops being active the card is back in
the club, with no extra transition to maintain and no need to invent an
"unlisted transfer-list" wire shape (`tradeState` has no verified spelling for
that state, and guessing enum spellings is the documented client-freeze class).

A bare pile move therefore no longer hides a card. That is deliberate: our
`/tradePile` shows only active listings, so hiding on the move alone would
reintroduce the limbo it is meant to prevent.

Verified live: clubPlayers 1961 -> 1965 (exactly the one listed card hidden, the
4 stranded cards recovered); listed wire still absent from /club; counts and
tradePile unchanged. 14 targets green + clippy clean.
2026-08-17 18:10:32 +00:00
funman300 aa2abc2772 fix(market): make the transfer market work end-to-end (live-verified)
Four defects found by driving a real FIFA 17 client. Each was independently
sufficient to break listing, so all four had to go:

1. Every owned card was shaped `untradeable: true` (adapter item.rs), so the
   client greyed out "Place/List on Transfer Market" for the whole club. Owned
   and pack-pulled cards are TRADEABLE in FIFA 17; the oracle forces this off
   for owned copies too (item_def keeps `true`; instances do not).

2. `POST /auctionhouse` required `itemData.resourceId`, which the client's
   FutISStart body never sends (the oracle lists by wire id ALONE). Missing it,
   the handler fail-closed and returned 200 while persisting NOTHING. It now
   resolves server-side: wire id -> Core owned instance -> its card_id (minted on
   a synthetic buy) + FIFA resourceId (the auction record). This also enforces
   that a listing can only name a card the club actually owns.

3. An auction record's `itemData` was a 4-field STUB, so the Transfer List had a
   row the client could not draw -> "1 item listed" but no visible sale. A
   listing now persists a full shaped-card SNAPSHOT (new `listings.item_json`,
   additive migration) built by the same `shape_item` shaper `/club` and the
   squad projection use, so the auction card renders identically to the club
   card. The seller's own pile stamps `itemState: listFS`; market search keeps
   `forSale` (the oracle distinguishes these).

4. `/tradePile/counts` shared a handler with `/tradePile`. They are DIFFERENT
   deserializers: `/counts` is FutGetAuctionCount, five scalar ints
   (count/maxAuctionsAllowed/offered/selling/sold) that it reads and skips
   everything else. Served the `auctionInfo` body it left every count at 0, so
   the Transfer List screen showed no active sale while the hub tile showed one.
   New Route::MarketCounts, classified BEFORE the base tradePile matcher (which
   also accepts the /counts path).

Also: a listed card no longer appears in the club. `/club` and the hub's
`clubPlayers` now exclude the transfer pile. Pile membership is host-owned state
Core cannot filter on, so when anything is hidden `/club` reuses the existing
local-filter path (the one `rare=SP` already needed) and paginates the
club-visible set -- letting Core paginate would return short pages. With nothing
hidden the fast Core-paginated path is untouched, and only an EXPLICIT non-club
pile hides a card, so no-pile-row items still default to the club.

Fixed 5 pre-existing test fixtures across 4 targets that listed FABRICATED wire
ids -- only "valid" because the old handler skipped the ownership check.

Tests: 14 targets green + clippy clean, incl. new coverage for the 5-int tally
(asserting it must NOT carry auctionInfo), the full-card snapshot + listFS, and
club pile-exclusion with full-width pagination. The differential test against the
live Python oracle passes.

Verified live on prod: listed=true with a 21-field snapshot; counts
{count:1,selling:1,maxAuctionsAllowed:100}; tradePile renders the 94-rated card;
clubPlayers 1966 -> 1961 (exactly the 5 trade-pile items); listed wire absent
from the club page. Operator confirmed the card is visible in the Transfer List.
2026-08-17 18:03:06 +00:00
funman300 1aa84afa9a feat(host): migrate item-defs + marketdata UTAS reads to Rust
Two more real client-hit reads move off the Python proxy:

- GET /item/resource, /defid (Route::ItemDefs): build {itemData:[item_def…]}
  for every >=3-digit id in the query, replicating the oracle's item_def
  (assetId = resourceId & 0xffffff; hardcoded Ronaldo asset 20801 + a generic
  "Player" 75 CM placeholder). The client renders the real card from its local
  DB, so the placeholder is exact parity.
- GET /marketdata (+ /marketdata/pricelimits) (Route::MarketData): suggested
  pricing, constant band 150..15000. /pricelimits returns a BARE ARRAY (one
  {defId,minPrice,maxPrice} per queried defId); plain /marketdata returns an
  OBJECT {minPrice,maxPrice}. The container type is load-bearing — object-where-
  array froze a live client at the listing screen, so the handler picks it from
  the path.

Adds extract_long_ints / extract_defid_param query parsers, shape+parser unit
tests (incl. the freeze-critical container-type assertions), and classify-table
coverage. Deployed to prod-host 2026-08-17; verified owner=RUST 200 for all four
(Ronaldo/placeholder resolve, pricelimits=array, marketdata=object).

Docs: PRODUCTION_AUTHORITY_MATRIX + PYTHON_RETIREMENT_PLAN updated. Remaining
Python tail is now only mutation (user/club), no-Core-model (squad/<n>), and
unimplemented modes (draft/leaderboards/sbs).
2026-08-17 16:21:17 +00:00
funman300 33e9118329 feat(host): migrate flag-off UTAS reads (season/tournament/champion/clubUser/user-list) to Rust
FUT modes (Seasons/Tournaments/FUT Champions) and the club-identity service are
disabled in this emulator, so these GET reads return {} verbatim from the Python
oracle. Serve them directly from Rust via a new Route::FeatureOffEmpty +
non_economy::feature_off_body() -> {} (byte-identical to the flag-off oracle),
reducing the proxied Python surface.

- The mutating club rename (user/club) stays on Python (needs a Core write).
- Enabling a mode later requires a real Rust handler here, never a Python
  fallback (no split authority).
- classify tests: 5 routes owned + user/club/wrong-method lookalikes stay
  Passthrough; updated the stale clubUser assertion.
- Deployed to prod-host 2026-08-17; verified owner=RUST 200 {} for all five.

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

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

Docs: PRODUCTION_AUTHORITY_MATRIX, PYTHON_RETIREMENT_PLAN, MATCH_LIFECYCLE, and
route-shapes-2026-08-17 reference fixtures for the still-Python tail.
2026-08-17 16:04:20 +00:00
funman300 42fd3c7e90 core: deploy correctness fixes (SBC exploit + economy TOCTOU) + docs
Bump openfut-core gitlink to 68d1065 (correctness fixes: SBC duplicate-card
exploit, non-atomic economy CAS guards, season/checkin panics, sbc_submissions
club_id migration 0019). Deployed to prod-core (DB migration ver 18 -> 19).

Add docs/CORE_CORRECTNESS_ISSUES.md (audit + Resolution) and
docs/OVERNIGHT_HANDOFF_2026-08-17.md.
2026-08-17 16:01:27 +00:00
funman300 0bc71dbd74 docs(evidence): capture full-length UTAS responses + userMassInfo envelope
Fix extractor truncation (bound each HTTP message by Content-Length): userMassInfo
(8 KB) and purchasegroup responses are now complete in the committed corpus, not
cut at 4 KB. Document the userMassInfo envelope contract (target shape for a future
full-Rust migration; needs the clubAbbr/established account triad Core lacks).
2026-08-17 04:13:55 +00:00
funman300 2ecd830d75 docs(evidence): commit fresh sanitised UTAS wire captures (2026-08-15 live A/B)
Rebuilds the primary-capture corpus lost to .gitignore (Known Issues #200): 10
real-client requests + 12 responses captured during the post-P1 staging A/B on a
real FIFA 17 client, sanitised (SID/authCode/deviceId/MAC/tokens redacted; raw pcap
withheld). Documents the account/sync, empty-My-Packs 65534 sentinel, and userMassInfo
contracts, incl. the finding that account/sync is coupled to Python active-profile
selection (so it can't migrate standalone from the userMassInfo hybrid).
2026-08-17 04:09:56 +00:00
funman300 3a51b0ebd4 docs: correct wrong Fire2 header traps in heat2.py + fifa-blaze frame.rs
Both files documented a wrong Fire2 header layout as authoritative, the reader
trap called out in Known Issues:
- heat2.py's module docstring labelled its >IHHHHB3s header 'VALIDATED'. The
  round-trip only validates the payload length + TDF body; decode->encode with the
  same mislabelled header trivially reproduces the capture, so it never tested the
  [10:16] field boundaries. Marked superseded; cite the proven layout; warn at
  build_fire2_frame. Code unchanged (dead tooling).
- fifa-blaze frame.rs: see submodule commit f4f3396.

Bumps fifa-blaze submodule eccd46f -> f4f3396 (FIFA23 stub; not in the prod
container; no prod impact).
2026-08-16 21:29:52 +00:00
funman300 ad406f21bd fix(tls): share bare-probe classification across all FIFA-facing TLS hosts
A reachability probe (TcpStream::connect then drop; the launcher preflight makes
them) reaches a TLS acceptor as 'unexpected EOF' — byte-identical to the
certificate mismatch that cost three live gates. The redirector classified the
opening before the acceptor to keep a benign probe from forging a TLS fault, but
the roster host (the second FIFA-facing TLS host) did not, so the documented
hazard 'remains in any other TLS host that has not adopted it' was live there.

Lift the pure policy (PeerOpening + classify_opening) plus a peer_opening(&TcpStream)
peek helper into the shared openfut-tls crate (game-independent; +unit tests).
The redirector now re-exports them (public API + its probe_classification test
unchanged; behaviour identical). The roster host adopts them: a ProbeCount, a
probes() handle, and a pre-acceptor peek that logs PROBE and returns instead of
failing the handshake. New roster probe_classification integration test (3 cases:
bare probe classified, real client after a probe still served 200, speaks-then-
fails still reported as a fault). Full workspace tests green; clippy -D clean.
2026-08-16 20:30:50 +00:00
funman300 12fb9fc38b chore: update workspace Cargo.lock after excluding openfut-hook
openfut-hook (now its own workspace root) and its windows-sys deps are no longer
part of this workspace's lockfile.
2026-08-15 19:32:18 +00:00
funman300 7b580a0070 chore: bump openfut-hook clippy -D warnings cleanup (0d3f33c -> d1a71bd) 2026-08-15 19:31:25 +00:00
funman300 22443a3810 build(hook): exclude openfut-hook from workspace so its release profile applies
openfut-hook (Windows version.dll injected into the FIFA client) declared a
[profile.release] with panic=abort/strip/opt-level=s that Cargo silently ignored
because it was a non-root workspace member (per-package `panic` overrides are
forbidden). Move it out of `members` into `exclude`; the submodule now carries a
matching empty [workspace] table so it builds as its own root. Fixes cross-FFI
panic-unwind UB in the injected DLL, shrinks it 1200126 -> 861696 B, and lands the
artifact in openfut-hook/target/ (matching launcher config.rs hook_dll_path).

Bumps openfut-launcher submodule ca7ce26 -> 0d3f33c.
2026-08-15 19:25:19 +00:00
funman300 0fce1e521c docs: mark club/stats/{year,consumables} Rust-owned in authority matrix
Global MY-CLUB stat set now Rust (staging-verified RUST route=club-stats
owned=1982 players=1962); only club/stats/{country,league,team} nation-bucket
context sub-screens remain proxied (low value, inert atoms).
2026-08-15 18:04:23 +00:00
funman300 71fcf5e251 feat(fifa17): own club/stats/{year,consumables} in Rust (Core-accurate)
Migrate the MY CLUB stat set from the Python proxy to a Rust handler computing
Core-accurate counts: player tiers + rare from the collection, staff/consumable
families from catalog kind+subtype, per-nation buckets via the reverse entity
resolver. Faithful port of fut_club_stats.py (VOCAB + global_counts +
context_rows). Unlike the oracle (stale profile + synthetic consumable shelf),
this reflects the real imported content (incl. the content-gap consumables/staff).
Fail-closed 503 on Core error. club/stats/country|league|team sub-screens remain
Python (documented). Adds adapter club_stats module (5 tests), host handler +
classify arm + resolver subtype_of/rareflag_of, ownership + integration tests;
reachability tool splits club/stats global(migrated) vs context(residual).
2026-08-15 17:56:37 +00:00
funman300 979e71fbea docs: record precise blockers for remaining Python non-economy routes 2026-08-15 17:31:15 +00:00
funman300 45e0b0bd95 docs: mark hub + club/stats/staff migrated in authority matrix 2026-08-15 17:18:55 +00:00
funman300 70eb3fc13f feat(fifa17): own FUT hub tile counts in Rust
Migrate GET /hub from the Python proxy to a Rust handler deriving counts from
authoritative state: clubPlayers = owned PLAYER cards in Core (consumables/staff
excluded via catalog kind; may be lower than Python's profile count by the
deferred Legend instances = DIFFERENT-BY-DESIGN), auction/tradePile counts from
the durable market store via the async bridge. Fail-closed 503 on Core error;
market read failure degrades cosmetic counts to 0. Adds classify arm, handler,
ownership + integration tests; reachability tool marks hub migrated.
2026-08-15 17:18:06 +00:00
funman300 b30aa352f6 docs: record post-P1 candidate migration status + clientdata Core blocker 2026-08-15 17:13:47 +00:00
funman300 67cc33cfee feat(fifa17): own club/stats/staff (empty stat set) in Rust
Migrate GET club/stats/staff from the Python proxy to a Rust static handler.
The production oracle returns {} for the staff-bonus stat set (deliberately
empty); the Rust host now owns it (owner=RUST route=club-stats-staff). Updates
classify(), the pre-existing near-miss test (now club/stats/year), the ownership
matrix test, and the no-fallback integration test. club/stats/{year,consumables}
remain Python (aggregation) pending the Core-derived club-stats migration.
2026-08-15 17:13:16 +00:00
funman300 6eec3b9ec7 test(fifa17): add UTAS route-reachability reporter (Python-hit gate)
Parses the host owner= dispatch log into per-owner + per-domain counts and gates
on the post-P1 invariants: economy Python hits == 0 (P1 regression), migrated
non-economy routes (accountinfo/settings/leaderboards/match-reset/phishing) ==
0, and residual Python domains == documented set. Read-only; staging-preflight
and Phase 40 live-ownership use.
2026-08-15 17:01:11 +00:00
funman300 57773b98ec docs: Python retirement readiness classification (post-P1) 2026-08-14 05:36:32 +00:00
funman300 fe9b899a0e docs(fifa17): record .105 Legends unrecoverable verdict (dcplayernames empty) 2026-08-14 05:26:02 +00:00
funman300 abe9e663c1 feat(fifa17): import consumable + staff content as first-class Core content
Close 20 of the 33-record content gap (17 consumables + 3 staff; 13 Legends are
unrecoverable from PC data). Verdict A (no Core change): consumables/staff become
ordinary Core CardDefinitions (neutral player fields + honest family/role names)
and owned instances via the SAME generic import path; a catalog kind lets the
adapter exclude them from the player-only /club projection.

- adapter fut::content_taxonomy: evidence-based cardsubtypeid->family/label
  (Ghidra-derived ranges) + staff role map; unknown subtype => defer, never fabricate.
- adapter catalog: Fifa17CardIdentity/RawCard gain optional kind+subtype
  (backward-compat: legacy catalogs load as player); kind_of/subtype_of lookups.
- adapter item/club_response: shape_club_response excludes non-player kinds
  (ShapeStats.excluded_non_player); ItemIdentityResolver::kind_of default=Player.
- host Fifa17IdentityResolver overrides kind_of to delegate to the catalog so
  /club excludes consumables/staff in production.
- import: Item gains cardsubtypeid/cardassetid/amount/contract; plan_non_player_definitions
  (resourceId-grouped, subtype-consistency gated); emit_content writes non-player
  defs + catalog kind + manifest; apply mints owned instances via owned_item_id.

Real profile 33068179: 1962 players + 20 non-player = 1982 owned; 18 non-player
defs (16 consumable + 2 staff, dup resourceIds shared); 0 deferred non-player; 0 blockers.
2026-08-14 05:26:02 +00:00
funman300 f5a33eb58c test(fifa17): prove non-economy routes are Rust-owned with no Python fallback 2026-08-14 05:08:37 +00:00
funman300 a85090c3c6 feat(fifa17): own non-economy static + security-question routes in Rust
Migrate 5 non-economy UTAS route families from the Python oracle proxy to
Rust host ownership: user/accountinfo, settings, leaderboards/options,
match/reset, and phishing/{trusteddevice,question,validate}.

- adapter fut::non_economy: pure IO-free shapers matching the observed prod
  oracle bodies + a verbatim port of security_question_route (stateless ack;
  answer never stored/compared; trusted-device is an invariant constant).
- host: Route variants + classify() arms + owner=RUST dispatch; the
  security-question X-UT-SID gate reuses SessionStore::session_known.
- tests: 9 adapter unit tests (contract) + host non_economy_route_ownership
  (classify + classify_economy negatives).
2026-08-14 04:57:28 +00:00
funman300 97d48d8371 docs: record post-P1 production authority matrix + FIFA17 content completeness 2026-08-14 04:57:28 +00:00
59 changed files with 8683 additions and 333 deletions
Generated
-7
View File
@@ -3185,13 +3185,6 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "openfut-hook"
version = "0.1.0"
dependencies = [
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "openfut-host-config" name = "openfut-host-config"
version = "0.1.0" version = "0.1.0"
+8 -1
View File
@@ -15,7 +15,14 @@ members = [
"openfut-import-fifa17", "openfut-import-fifa17",
"openfut-bridge", "openfut-bridge",
"openfut-launcher", "openfut-launcher",
"openfut-launcher/openfut-hook",
"fifa-blaze/crates/blaze-proto", "fifa-blaze/crates/blaze-proto",
"fifa-blaze/crates/server", "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"]
+175
View File
@@ -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.
+79
View File
@@ -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
+198
View File
@@ -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.
+208
View File
@@ -0,0 +1,208 @@
# FIFA 17 FUT Match Lifecycle
Design + contract reference for the **FUT match loop** as OpenFUT implements it on
the backend/responder side. Consolidates knowledge previously scattered across
`docs/PROJECT_STATE.md`, `fifa17-recon/tools/utas_server.py` (`match_route`),
`openfut-utas-host` (Rust economy END leg), `fifa17-recon/tools/test_match_lifecycle.py`,
and the vault (`Protocol Findings.md`, `Project State.md`, `Known Issues.md`).
Evidence labels: **OBSERVED** (live), **PROVEN** (test/static-analysis),
**HYPOTHESIS** (reasoned, not yet live-confirmed).
> ## Status caveat (read first)
> **No football match has ever started or completed in FIFA against this stack.**
> The lifecycle below is validated by CardsDLL static analysis (RPC descriptor
> blocks) + isolated persistence replay (`test_match_lifecycle.py`), **not** in-game
> acceptance. The reward amounts are FUT-plausible env-tunable defaults, **not**
> reversed values. Two blockers keep `FUT_MODES` **off by default** (see
> [Open questions](#open-questions--blockers)).
## Scope
This documents the **UTAS responder handshake + economy reward** for a match — the
HTTP calls CardsDLL makes around a match and the state they mutate. It does **not**
cover the actual football simulation (Blaze game-server side, "past the FUT hub"),
which remains unverified.
## The loop: a four-call state machine
CardsDLL issues six match RPCs; four form the playable loop. All share the base
path `ut/<sku>/match`; the operation is discriminated by **suffix + body**, not by
HTTP verb (verb selection lives outside CardsDLL, so the classifier is verb-agnostic).
```mermaid
stateDiagram-v2
[*] --> Created: POST /match (no matchId)
Created --> Ready: POST|PUT /match/ready {matchId}
Ready --> Playing: POST /match {matchId} (bare path + int matchId)
Playing --> Ended: POST|PUT|DELETE /match/end {matchId, endReason, ...}
Ended --> [*]: rewards credited, W/D/L + matchesPlayed persisted
```
### Call classifier (`_match_call`, utas_server.py:3325)
The discriminator (**PROVEN** from CardsDLL RPC descriptors):
1. path ends `/match/end` → **END** (routed as `FutDestroyMatch` regardless of verb).
2. body has integer `matchId` on the **bare** `/match` path → **PLAY** (`FutPlayGame`).
CREATE and PLAY share `ut/<sku>/match`; the presence of an int `matchId` is the
only discriminator — this is why a bare `/match` carrying `matchId` must NOT
allocate a new match.
3. path ends `/match/ready` → **READY** (`FutMatchReady`).
4. otherwise → **CREATE** (`FutCreateMatch`).
## Per-call contracts
### CREATE — `POST /ut/<sku>/match` (empty/no `matchId`)
CardsDLL: `FutCreateMatch` @ `0x180120380`; deserializes `startDateTime`(740,int),
`reportIdEnabled`(641,bool). `squad`(717,nested) is a **FREEZE-RISK** and is omitted
(SKIP-safe).
Response (`match_route`, utas_server.py:3399):
```json
{"startDateTime": <unix_ts:int>, "reportIdEnabled": false, "id": <matchId:int>}
```
Effect: allocates a match id; **advances `nextItemId` by 1** (PROVEN,
`test_match_lifecycle.py:51`).
### READY — `POST|PUT /ut/<sku>/match/ready` (`{matchId}`)
CardsDLL: `FutMatchReady` — no deserializer at all on the request; the server
response parser has two scalar members + one nested member.
Response (`match_ready_body`, utas_server.py:3353):
```json
{"matchId": <int>, "opponentPersonaId": <int, default 0>}
```
- `opponentPersonaId` defaults to `0` — a neutral placeholder, **never** the
logged-in user's persona.
- The parser also has a nested `items` member (the opponent squad). It is **omitted
deliberately** until the opponent-squad item contract is recovered from a live
capture; unrecognized/absent members are skip-safe. **This omission is one of the
two blockers.**
### PLAY — `POST /ut/<sku>/match` (bare path, `{matchId:int}`)
CardsDLL: `FutPlayGame` — no request deserializer.
Response: `{}` (empty). Effect: **none** — must NOT allocate a match or advance
`nextItemId` (PROVEN, `test_match_lifecycle.py:62-63`). This is purely a client
keepalive/transition ack.
### END — `POST|PUT|DELETE /ut/<sku>/match/end` (`{matchId, endReason, myMatchStats, opponentMatchStats}`)
CardsDLL: `FutDestroyMatch` @ `0x180121b60` — **the rewards call**. Routed as
DestroyMatch regardless of verb (`FUT_MATCH_END`, default ON).
> **Wire path (Rust vs Python).** The Rust-owned reward is classified by `classify_economy`
> on **`POST /ut/delete/game/<sku>/match`** — EA/CardsDLL tunnels DELETE-semantics ops through the
> `/ut/delete/game/` prefix (`FutDestroyMatch` = `DELETE ut/%s/match/{id}`). Python's `match_route`
> additionally accepts `/ut/game/<sku>/match/end`. Which exact form the retail client emits is
> unverified (no match ever played); the Rust economy owns the `/ut/delete/game` form, and a
> `/ut/game/.../match/end` would fall to Python. Both credit the same reward shape.
Request fields that matter:
- `endReason` (atom 260) — **STRING enum, the AUTHORITATIVE result signal**. A score
comparison is NOT how the client reports the outcome. Nine values, mapped to a
win/draw/loss bucket:
| endReason | bucket |
|---|---|
| `WIN`, `DNF_WIN` | won |
| `DRAW`, `DNF_DRAW`, `NO_CONTEST` | draw |
| `LOSS`, `DNF_LOSS`, `DNF`, `QUIT` | loss |
| (missing/unknown) | draw (neutral fallback — credits without inventing a win) |
- `myMatchStats` / `opponentMatchStats` — literal-keyed objects, 15 int fields each,
first is `goals`. **Omitted by the client when `endReason` is `DNF`/`QUIT`**, so
nothing may require them. Used only as a fallback outcome probe if `endReason` is
absent.
Response (`destroy_match_body` / Rust `build_match_reward_body`) — every field a
**top-level scalar** (zero freeze risk) except the deliberately nested reward:
```json
{
"allCoins": <post-credit balance:int>,
"matchCoins": <per-result coins:int>,
"seasonCoins": 0,
"tournamentCoins": 0,
"boostConis": 0, // EA's typo — exact key required
"participationAward": <int>,
"teamOfTournamentWinner": false,
"gameModeAward": { "coins": <total award:int> }
}
```
> **Critical correction (2026-08-04):** the reward `coins` (atom 149) is read by the
> deserializer **only inside `gameModeAward`**, never as a top-level key. An earlier
> top-level `"coins"` was silently skipped and never reached the client — the one
> field most obviously named "the reward" was the one going nowhere.
Effect (persisted to the active FUT save): credit coins; increment the matching
`record.{won,draw,loss}`; increment `matchesPlayed` (PROVEN,
`test_match_lifecycle.py:74-81`).
## Reward policy
Env-tunable defaults (FUT-plausible, **not reversed** — `economy_policy.rs:20-36`,
`utas_server.py:3201-3206`):
| Result | Match coins | Participation | Total |
|---|---|---|---|
| Win | 400 (`FUT_MATCH_COINS_WIN`) | 0 (`FUT_MATCH_PARTICIPATION`) | 400 |
| Draw | 200 (`FUT_MATCH_COINS_DRAW`) | 0 | 200 |
| Loss | 100 (`FUT_MATCH_COINS_LOSS`) | 0 | 100 |
`allCoins` = post-credit balance; `gameModeAward.coins` = per-result + participation.
## Ownership split (Rust vs Python)
The match loop is **partially migrated**. Only the coin-crediting END leg is
economy state, so only it is Rust/Core-owned; the CREATE/READY/PLAY legs are still
served by the Python oracle.
| Call | Owner | Where |
|---|---|---|
| CREATE | Python | `utas_server.py::match_route` (via host `Route::Passthrough`) |
| READY | Python | `utas_server.py::match_route` |
| PLAY | Python | `utas_server.py::match_route` |
| END (reward) | **Rust/Core** (on `POST /ut/delete/game/<sku>/match`) | `EconomyRoute::MatchEnd` → `handle_match_end` → `build_match_reward_body`; outcome via `economy_policy::match_result_from_reason`, coins via `match_result_coins`/`match_reward_total`. 503 on Core error, never Python. Python also accepts `/ut/game/<sku>/match/end`. |
END is classified in `classify_economy` (`openfut-utas-host/src/lib.rs`) and dispatched
by the economy authority barrier ahead of the general classifier — so it can never
also reach the Python passthrough (NEVER-BOTH). The Rust END writes the coin reward
through the single Core economy transaction (`grant_reward`); W/D/L record + match
count still live in the Python save until CREATE/READY/PLAY migrate.
## Test coverage
`fifa17-recon/tools/test_match_lifecycle.py` (PROVEN, isolated — temp profile, no
live server): drives CREATE→READY→PLAY→END and asserts:
- CREATE returns `reportIdEnabled:false` + advances `nextItemId` by 1.
- READY returns exactly `{matchId, opponentPersonaId:0}`.
- PLAY returns `{}` and does **not** advance `nextItemId`.
- END credits `MATCH_COINS["won"] + MATCH_PARTICIPATION`, persists
`record == {won:1,draw:0,loss:0}` and `matchesPlayed == 1`.
## Open questions / blockers
1. **READY `items` contract (opponent squad)** — the nested `items` member of
`FutMatchReadyServerResponse` is unserved pending a live capture. Whether the
client requires it present/non-empty to enter a match is unknown. **Blocker.**
2. **Client mode-entry gate** — reaching a match from the FUT hub UI is unverified;
`FUT_MODES` stays **off by default** (the season/tournament routes return `{}`).
3. **No in-game acceptance** — every claim here is static-analysis + isolated replay.
The first live match is expected to reveal whether the static read was complete
(every match body is logged for exactly this reason).
4. **Football simulation** — the actual gameplay (Blaze game-server) is out of scope
here and unverified.
## Sources
- `docs/PROJECT_STATE.md` (match lifecycle status), `docs/direction.md` (Tier-2 loop).
- `fifa17-recon/tools/utas_server.py`: `_match_call` (3325), `match_ready_body` (3346),
`match_route` (3357), `destroy_match_body` (3281), `_match_result` (3236),
`MATCH_COINS`/`MATCH_PARTICIPATION` (3201), `_END_REASON` (3229); CardsDLL RPC
descriptor block (3183-3197).
- `openfut-adapter-fifa17/src/fut/economy_policy.rs` (reward policy + outcome map).
- `openfut-utas-host/src/lib.rs`: `EconomyRoute::MatchEnd`, `handle_match_end`,
`build_match_reward_body`.
- `fifa17-recon/tools/test_match_lifecycle.py` (lifecycle regression).
- Vault: `02 Reverse Engineering/FIFA 17/Protocol Findings.md`,
`06 Agent Memory/{Project State,Known Issues}.md`.
+165
View File
@@ -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`.
+165
View File
@@ -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).
+45
View File
@@ -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
}
+27 -9
View File
@@ -15,16 +15,27 @@ reimplementations (the `tdf` crate cloned in this scratchpad), which were used
only as a cross-check of *structure*, never copied. only as a cross-check of *structure*, never copied.
NO EA/FIFA leaked source was consulted. NO EA/FIFA leaked source was consulted.
VALIDATED RULES (byte-exact round-trip against the 219-byte capture) VALIDATED RULES (the TDF body; byte-exact round-trip against the 219-byte capture)
-------------------------------------------------------------------- ---------------------------------------------------------------------------------
Fire2 frame header, 16 bytes big-endian: Fire2 frame header, 16 bytes big-endian.
[0:4] u32 payload length (bytes after the header)
[4:6] u16 always 0 (observed) !!! 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 [6:8] u16 component
[8:10] u16 command [8:10] u16 command
[10:12]u16 error / msgId [10:13] u24 msgNum (this file WRONGLY split it as [10:12] msgId + [12] msgType)
[12] u8 msgType (0x01 ping, 0x02 request, 0x03 pong/response) [13] u8 (msgType << 5) | (userIndex & 0x1F)
[13:16]3 reserved bytes (observed 00 00 00) [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. 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, def build_fire2_frame(component: int, command: int, msgType: int,
msgId: int, tdf_bytes: bytes) -> bytes: 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) tdf_bytes = bytes(tdf_bytes)
hdr = struct.pack(">IHHHHB3s", len(tdf_bytes), 0, component & 0xFFFF, hdr = struct.pack(">IHHHHB3s", len(tdf_bytes), 0, component & 0xFFFF,
command & 0xFFFF, msgId & 0xFFFF, msgType & 0xFF, command & 0xFFFF, msgId & 0xFFFF, msgType & 0xFF,
+76
View File
@@ -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)
+73
View File
@@ -20,6 +20,8 @@ use std::collections::HashMap;
use serde::Deserialize; use serde::Deserialize;
use crate::fut::content_taxonomy::ContentKind;
/// The FIFA 17 render identity of a card definition. `version` is the high byte /// 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. /// of `resource_id`; `asset_id` (the low 24 bits) is the real FIFA player id.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -30,6 +32,12 @@ pub struct Fifa17CardIdentity {
/// FIFA wire `rareflag` (rare/special card TYPE). Carried so specials render /// FIFA wire `rareflag` (rare/special card TYPE). Carried so specials render
/// as specials; observed metadata, not a guessed label. /// as specials; observed metadata, not a guessed label.
pub rareflag: i64, 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. /// 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. /// behaviour; the production catalog carries the observed value.
#[serde(default = "default_rareflag")] #[serde(default = "default_rareflag")]
rareflag: i64, 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 { fn default_rareflag() -> i64 {
@@ -169,6 +185,8 @@ impl Fifa17CardCatalog {
version: rc.version, version: rc.version,
resource_id, resource_id,
rareflag: rc.rareflag, 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) 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 { pub fn len(&self) -> usize {
self.by_card.len() self.by_card.len()
} }
@@ -335,4 +368,44 @@ mod tests {
assert_eq!(ron.version, 0); assert_eq!(ron.version, 0);
assert_eq!(ron.resource_id, 20801); 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 serde_json::{json, Value};
use crate::fut::content_taxonomy::ContentKind;
use crate::fut::entities::ReverseEntityResolver; use crate::fut::entities::ReverseEntityResolver;
use crate::fut::item::shape_item; use crate::fut::item::shape_item;
// Re-exported so existing `club_response::{…}` callers keep working; the types // 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 out = Vec::with_capacity(items.len());
let mut stats = ShapeStats::default(); let mut stats = ShapeStats::default();
for item in items { 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) { match ident.resolve(item) {
Some(id) => { Some(id) => {
out.push(shape_item(item, id, ent)); out.push(shape_item(item, id, ent));
@@ -193,4 +200,66 @@ mod tests {
"only itemData at top level" "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");
}
}
}
+19 -1
View File
@@ -24,6 +24,7 @@
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::fut::content_taxonomy::ContentKind;
use crate::fut::entities::ReverseEntityResolver; use crate::fut::entities::ReverseEntityResolver;
/// One owned item in game-independent terms, as read from Core's inventory. /// 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. /// "no real FIFA asset id known" → the caller must not fabricate one.
pub trait ItemIdentityResolver { pub trait ItemIdentityResolver {
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity>; 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). /// Diagnostics from shaping (safe to log — counts only).
@@ -76,6 +86,9 @@ pub trait ItemIdentityResolver {
pub struct ShapeStats { pub struct ShapeStats {
pub emitted: usize, pub emitted: usize,
pub dropped_no_asset: 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; /// Quick-sell / discard value by rating tier (mirrors Core's quick-sell table;
@@ -133,7 +146,12 @@ pub fn shape_item(
"attributeList": attribute_list, "attributeList": attribute_list,
"itemState": "free", "itemState": "free",
"owners": 1, "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, "contract": 7,
"fitness": 99, "fitness": 99,
"discardValue": discard_value(item.rating), "discardValue": discard_value(item.rating),
+3
View File
@@ -6,10 +6,13 @@
//! socket — a Rust UTAS host wires it to Core later. //! socket — a Rust UTAS host wires it to Core later.
pub mod catalog; pub mod catalog;
pub mod club_response; pub mod club_response;
pub mod club_stats;
pub mod content_taxonomy;
pub mod economy; pub mod economy;
pub mod economy_policy; pub mod economy_policy;
pub mod entities; pub mod entities;
pub mod item; pub mod item;
pub mod non_economy;
pub mod owned_query; pub mod owned_query;
pub mod pack_content; pub mod pack_content;
pub mod squad; 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");
}
}
+25 -2
View File
@@ -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 // 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 // squad-write path uses, over the raw source squad. The resolver maps every
@@ -253,8 +270,14 @@ pub fn plan_apply(
request, request,
mappings, mappings,
watermark: report.identity.source_watermark, watermark: report.identity.source_watermark,
supported_instances: report.identity.import_wire_ids.len(), supported_instances: report.identity.import_wire_ids.len()
deferred_instances: report.deferred_instances(), + 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(), source_fingerprint: snapshot_fingerprint.to_string(),
}) })
} }
+273 -6
View File
@@ -34,6 +34,7 @@ pub mod apply;
pub mod model; pub mod model;
use model::{Item, Profile}; use model::{Item, Profile};
use openfut_adapter_fifa17::fut::content_taxonomy::{consumable_family, staff_role, ContentKind};
// ----------------------------------------------------------------- roster // ----------------------------------------------------------------- roster
@@ -521,6 +522,157 @@ pub fn plan_definitions(
plan 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 // --------------------------------------------------------------- identity
#[derive(Debug, Default)] #[derive(Debug, Default)]
@@ -632,6 +784,8 @@ pub struct Report {
pub definitions: DefinitionPlan, pub definitions: DefinitionPlan,
pub identity: IdentityPlan, pub identity: IdentityPlan,
pub squad: SquadCoverage, pub squad: SquadCoverage,
/// Consumable + staff content (supported definitions + deferred groups).
pub non_player: NonPlayerPlan,
/// Unconsumed pack entitlements to seed (from `unopenedPackIds`). /// Unconsumed pack entitlements to seed (from `unopenedPackIds`).
pub unopened_pack_ids: Vec<i64>, pub unopened_pack_ids: Vec<i64>,
} }
@@ -720,6 +874,7 @@ pub fn analyze(
let identity = plan_identity(profile, &supported_rids); let identity = plan_identity(profile, &supported_rids);
let supported_wire: BTreeSet<i64> = identity.import_wire_ids.iter().copied().collect(); let supported_wire: BTreeSet<i64> = identity.import_wire_ids.iter().copied().collect();
let squad = plan_squad(profile, &supported_wire); let squad = plan_squad(profile, &supported_wire);
let non_player = plan_non_player_definitions(profile);
Report { Report {
game: "fifa17".to_string(), game: "fifa17".to_string(),
persona_id: profile.persona_id, persona_id: profile.persona_id,
@@ -731,6 +886,7 @@ pub fn analyze(
definitions, definitions,
identity, identity,
squad, squad,
non_player,
unopened_pack_ids: profile.unopened_pack_ids.clone(), unopened_pack_ids: profile.unopened_pack_ids.clone(),
} }
} }
@@ -740,6 +896,7 @@ impl std::fmt::Display for Report {
let d = &self.definitions; let d = &self.definitions;
let id = &self.identity; let id = &self.identity;
let sq = &self.squad; let sq = &self.squad;
let np = &self.non_player;
writeln!(f, "OpenFUT FIFA17 real-profile import — analysis")?; writeln!(f, "OpenFUT FIFA17 real-profile import — analysis")?;
writeln!(f, "=============================================")?; writeln!(f, "=============================================")?;
writeln!( writeln!(
@@ -819,11 +976,30 @@ impl std::fmt::Display for Report {
} }
writeln!( writeln!(
f, 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(), id.import_wire_ids.len(),
np.supported.iter().map(|d| d.wire_ids.len()).sum::<usize>(),
self.deferred_instances(), self.deferred_instances(),
self.counts.consumables, np.deferred_instances()
self.counts.staff
)?; )?;
let blockers = self.blockers(); let blockers = self.blockers();
if blockers.is_empty() { if blockers.is_empty() {
@@ -861,6 +1037,10 @@ pub struct EmitSummary {
pub catalog_entries: usize, pub catalog_entries: usize,
pub supported_instances: usize, pub supported_instances: usize,
pub deferred_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 /// 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)?; std::fs::create_dir_all(&manifest_dir)?;
// ---- PUBLIC: Core CardDefinition[] (matches openfut-core models::card) ---- // ---- PUBLIC: Core CardDefinition[] (matches openfut-core models::card) ----
let defs: Vec<serde_json::Value> = report let mut defs: Vec<serde_json::Value> = report
.definitions .definitions
.supported .supported
.iter() .iter()
@@ -904,15 +1084,58 @@ pub fn emit_content(
}) })
}) })
.collect(); .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"); let content_pack = content_dir.join("fifa17-production-cards.json");
write_json_pretty(&content_pack, &defs)?; write_json_pretty(&content_pack, &defs)?;
// ---- PUBLIC: host identity catalog {card_id: {asset_id, version, rareflag}} ---- // ---- PUBLIC: host identity catalog {card_id: {asset_id, version, rareflag}} ----
let mut cards = serde_json::Map::new(); let mut cards = serde_json::Map::new();
for d in &report.definitions.supported { 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( cards.insert(
d.card_id.clone(), 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!({ let catalog = serde_json::json!({
@@ -966,8 +1189,44 @@ pub fn emit_content(
"distinct_variants": c.distinct.len(), "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 supported_instances = report.identity.import_wire_ids.len();
let deferred_instances = report.deferred_instances(); 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!({ let manifest = serde_json::json!({
"generator": "openfut-import-fifa17", "generator": "openfut-import-fifa17",
"source_kind": "python-profile-observation", "source_kind": "python-profile-observation",
@@ -987,6 +1246,12 @@ pub fn emit_content(
}, },
"supported_definitions": supported, "supported_definitions": supported,
"deferred": deferred, "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"); let manifest_path = manifest_dir.join("fifa17-import-manifest.json");
write_json_pretty(&manifest_path, &manifest)?; write_json_pretty(&manifest_path, &manifest)?;
@@ -996,9 +1261,11 @@ pub fn emit_content(
host_catalog, host_catalog,
manifest: manifest_path, manifest: manifest_path,
definitions: report.definitions.supported.len(), definitions: report.definitions.supported.len(),
catalog_entries: report.definitions.supported.len(), catalog_entries: report.definitions.supported.len() + non_player_definitions,
supported_instances, supported_instances,
deferred_instances, deferred_instances,
non_player_definitions,
non_player_instances,
}) })
} }
+4
View File
@@ -123,6 +123,10 @@ fn run() -> Result<ExitCode> {
sum.supported_instances, sum.supported_instances,
sum.deferred_instances sum.deferred_instances
); );
println!(
" non-player : {} definition(s), {} instance(s) (consumable/staff)",
sum.non_player_definitions, sum.non_player_instances
);
} }
if do_apply { if do_apply {
+13
View File
@@ -63,6 +63,19 @@ pub struct Item {
pub league_id: Option<i64>, pub league_id: Option<i64>,
#[serde(rename = "attributeList", default)] #[serde(rename = "attributeList", default)]
pub attribute_list: Option<Vec<Attr>>, 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)] #[derive(Debug, Clone, Deserialize)]
+256
View File
@@ -626,3 +626,259 @@ fn apply_fails_gracefully_when_core_binary_missing() {
let err = apply_import(&plan, &paths, false).unwrap_err(); let err = apply_import(&plan, &paths, false).unwrap_err();
assert!(format!("{err:#}").contains("spawn core import"), "{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");
}
+6 -28
View File
@@ -233,32 +233,11 @@ pub fn serve(cfg: RedirectorConfig) -> std::io::Result<()> {
bind(cfg)?.run() bind(cfg)?.run()
} }
/// What a peer did with the connection before any TLS was attempted. /// Re-exported from the shared TLS crate. The classification policy now lives in
#[derive(Debug, Clone, Copy, PartialEq, Eq)] /// `openfut-tls` so every FIFA-facing TLS host shares one implementation; this
pub enum PeerOpening { /// host keeps naming them here so its public API and `probe_classification`
/// Connected and closed without sending anything: a reachability probe. /// integration test are unaffected.
ClosedWithoutSpeaking, pub use openfut_tls::{classify_opening, PeerOpening};
/// 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,
}
}
fn handle( fn handle(
stream: TcpStream, stream: TcpStream,
@@ -285,8 +264,7 @@ fn handle(
// exactly how the certificate mismatch that cost three live gates // exactly how the certificate mismatch that cost three live gates
// presented, so a benign probe forging it poisons the one channel this // presented, so a benign probe forging it poisons the one channel this
// project gates on. Classified here, the two are never confused again. // project gates on. Classified here, the two are never confused again.
let mut first = [0u8; 1]; if openfut_tls::peer_opening(&stream) == PeerOpening::ClosedWithoutSpeaking {
if classify_opening(&stream.peek(&mut first)) == PeerOpening::ClosedWithoutSpeaking {
probes.fetch_add(1, std::sync::atomic::Ordering::Relaxed); probes.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
log(&format!( log(&format!(
"conn-{id:04} {peer} PROBE: closed before sending a ClientHello (not a TLS fault)" "conn-{id:04} {peer} PROBE: closed before sending a ClientHello (not a TLS fault)"
+33 -3
View File
@@ -43,7 +43,7 @@
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream}; use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@@ -52,7 +52,7 @@ use openfut_adapter_fifa17::roster::{self, Method};
use openfut_http::drain_body; use openfut_http::drain_body;
pub use openfut_http::BodyRead; pub use openfut_http::BodyRead;
// The acceptor comes from the shared crate, so this host never names OpenSSL. // 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 mod config;
pub use config::RosterConfig; pub use config::RosterConfig;
@@ -112,6 +112,15 @@ pub struct ConnOutcome {
pub type Outcomes = Arc<std::sync::Mutex<Vec<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. /// Bounded: a host polled every few seconds must not accumulate forever.
const OUTCOME_HISTORY: usize = 64; const OUTCOME_HISTORY: usize = 64;
@@ -130,6 +139,7 @@ pub struct Server {
acceptor: Arc<SslAcceptor>, acceptor: Arc<SslAcceptor>,
cfg: Arc<RosterConfig>, cfg: Arc<RosterConfig>,
outcomes: Outcomes, outcomes: Outcomes,
probes: ProbeCount,
} }
impl Server { impl Server {
@@ -137,6 +147,11 @@ impl Server {
self.outcomes.clone() 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<()> { pub fn run(self) -> std::io::Result<()> {
let counter = AtomicU64::new(0); let counter = AtomicU64::new(0);
for incoming in self.listener.incoming() { for incoming in self.listener.incoming() {
@@ -144,7 +159,8 @@ impl Server {
let id = counter.fetch_add(1, Ordering::Relaxed) + 1; let id = counter.fetch_add(1, Ordering::Relaxed) + 1;
let (acceptor, cfg) = (self.acceptor.clone(), self.cfg.clone()); let (acceptor, cfg) = (self.acceptor.clone(), self.cfg.clone());
let outcomes = self.outcomes.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(()) Ok(())
} }
@@ -184,6 +200,7 @@ pub fn bind(cfg: RosterConfig) -> std::io::Result<Server> {
acceptor: Arc::new(acceptor), acceptor: Arc::new(acceptor),
cfg: Arc::new(cfg), cfg: Arc::new(cfg),
outcomes: Arc::new(std::sync::Mutex::new(Vec::new())), outcomes: Arc::new(std::sync::Mutex::new(Vec::new())),
probes: ProbeCount::default(),
}) })
} }
@@ -207,12 +224,25 @@ fn handle(
acceptor: &SslAcceptor, acceptor: &SslAcceptor,
cfg: &RosterConfig, cfg: &RosterConfig,
outcomes: &Outcomes, outcomes: &Outcomes,
probes: &ProbeCount,
) { ) {
let peer = stream let peer = stream
.peer_addr() .peer_addr()
.map(|a| a.to_string()) .map(|a| a.to_string())
.unwrap_or_else(|_| "?".into()); .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) { let mut tls = match acceptor.accept(stream) {
Ok(s) => s, Ok(s) => s,
Err(e) => { 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"
);
}
+59
View File
@@ -54,6 +54,52 @@ impl fmt::Display for TlsError {
impl std::error::Error 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. /// A TLS protocol version, expressed without an OpenSSL type.
/// ///
/// Adapters name the version window their client was observed to use; keeping /// 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 { mod tests {
use super::*; 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 /// 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 /// stack: this crate is game-independent and its tests must not depend on
/// FIFA material. Adapter crates test their own profiles. /// FIFA material. Adapter crates test their own profiles.
+102
View File
@@ -0,0 +1,102 @@
//! Durable FIFA 17 **client-data blob** store (`clientdata` / `userHubData`).
//!
//! FIFA persists opaque per-user client blobs via `PUT/POST …/clientdata/<key>`
//! and reads them back via `GET …/clientdata/<key>`. The blobs are entirely
//! client-defined (UI/hub state) — the server only round-trips them and never
//! interprets their contents. This store keeps them in memory keyed by
//! `<persona>:<key>` and persists the whole map to a JSON file on every write, so
//! the client's saved state survives a host restart.
//!
//! The blobs are non-authoritative client presentation state (NOT economy or
//! ownership), so a missing/unreadable backing file starts empty rather than
//! being a hard failure.
use std::collections::HashMap;
use std::path::PathBuf;
use parking_lot::Mutex;
use serde_json::Value;
/// In-memory client-data blobs, persisted to a JSON file on write.
pub struct ClientDataStore {
path: PathBuf,
map: Mutex<HashMap<String, Value>>,
}
impl ClientDataStore {
/// Open the store, loading any previously-persisted blobs. A missing or
/// unreadable file starts empty.
pub fn open(path: impl Into<PathBuf>) -> Self {
let path = path.into();
let map = std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice::<HashMap<String, Value>>(&b).ok())
.unwrap_or_default();
ClientDataStore {
path,
map: Mutex::new(map),
}
}
fn compound_key(persona: i64, key: &str) -> String {
format!("{persona}:{key}")
}
/// The stored blob for `<persona>:<key>`, or `None` if never written.
pub fn get(&self, persona: i64, key: &str) -> Option<Value> {
self.map.lock().get(&Self::compound_key(persona, key)).cloned()
}
/// Store `value` under `<persona>:<key>` and persist the whole map to disk.
/// The serialized snapshot is taken under the lock; the file write happens
/// after the lock is released.
pub fn put(&self, persona: i64, key: &str, value: Value) {
let snapshot = {
let mut map = self.map.lock();
map.insert(Self::compound_key(persona, key), value);
serde_json::to_vec(&*map).unwrap_or_default()
};
if let Some(parent) = self.path.parent() {
if !parent.as_os_str().is_empty() {
let _ = std::fs::create_dir_all(parent);
}
}
let _ = std::fs::write(&self.path, snapshot);
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn temp_path(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"openfut-clientdata-test-{}-{}.json",
std::process::id(),
tag
))
}
#[test]
fn round_trips_and_persists_across_reopen() {
let path = temp_path("roundtrip");
let _ = std::fs::remove_file(&path);
let store = ClientDataStore::open(&path);
assert_eq!(store.get(33_068_179, "userHubData"), None);
store.put(33_068_179, "userHubData", json!({"tiles": [1, 2, 3]}));
assert_eq!(
store.get(33_068_179, "userHubData"),
Some(json!({"tiles": [1, 2, 3]}))
);
// A different persona under the same key is isolated.
assert_eq!(store.get(1, "userHubData"), None);
// Reopening reads the persisted blob back.
let reopened = ClientDataStore::open(&path);
assert_eq!(
reopened.get(33_068_179, "userHubData"),
Some(json!({"tiles": [1, 2, 3]}))
);
let _ = std::fs::remove_file(&path);
}
}
+23 -1
View File
@@ -34,6 +34,11 @@ pub struct HostConfig {
/// Durable FIFA17 item-pile metadata DB (host-owned SQLite). Required; must /// Durable FIFA17 item-pile metadata DB (host-owned SQLite). Required; must
/// survive host restart. Env `OPENFUT_PILE_DB`. /// survive host restart. Env `OPENFUT_PILE_DB`.
pub pile_db_path: String, 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)] #[derive(Debug)]
@@ -68,6 +73,11 @@ fn required_i64_nonzero(key: &str) -> Result<i64, ConfigError> {
impl HostConfig { impl HostConfig {
pub fn from_env() -> Result<Self, ConfigError> { 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 { Ok(HostConfig {
listen_addr: required("OPENFUT_UTAS_HOST_ADDR")?, listen_addr: required("OPENFUT_UTAS_HOST_ADDR")?,
python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?, python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?,
@@ -76,10 +86,22 @@ impl HostConfig {
tables_dir: env::var("OPENFUT_FIFA17_TABLES_DIR") tables_dir: env::var("OPENFUT_FIFA17_TABLES_DIR")
.unwrap_or_else(|_| "fifa17-recon/data/tables".into()), .unwrap_or_else(|_| "fifa17-recon/data/tables".into()),
catalog_path: required("OPENFUT_FIFA17_CATALOG")?, catalog_path: required("OPENFUT_FIFA17_CATALOG")?,
identity_store_path: required("OPENFUT_IDENTITY_STORE")?,
persona_id: required_i64_nonzero("OPENFUT_PERSONA_ID")?, persona_id: required_i64_nonzero("OPENFUT_PERSONA_ID")?,
market_db_path: required("OPENFUT_MARKET_DB")?, market_db_path: required("OPENFUT_MARKET_DB")?,
pile_db_path: required("OPENFUT_PILE_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()
}
File diff suppressed because it is too large Load Diff
+587 -104
View File
@@ -26,9 +26,14 @@
use serde_json::{json, Value}; 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 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::pile_store::PileStore;
use crate::{CoreEconomy, CoreError, WireResponse}; 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 /// Shape one listing into the FIFA auction record (0x18013e410 fields), sourced
/// from durable listing state rather than a hardcoded sample pool. /// 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); let trade_id: i64 = l.listing_id.parse().unwrap_or(0);
// resourceId is the FIFA wire identity the client listed (never the Core // 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. // 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 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 item_id = l.wire_item_id.unwrap_or(trade_id);
let (trade_state, item_state, bid_state, current_bid) = match l.state.as_str() { // `expires` is SECONDS REMAINING (a 64-bit int), never an epoch, and the
"active" => ("active", "forSale", "none", 0), // client renders a LIVE COUNTDOWN from it and expects it to reach 0. A frozen
_ => ("closed", "free", "highest", l.buy_now_price), // 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!({ json!({
"tradeId": trade_id,
"itemData": {
"id": item_id, "id": item_id,
"resourceId": resource, "resourceId": resource,
"itemState": item_state, "itemState": item_state,
"untradeable": false, "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, "tradeState": trade_state,
"buyNowPrice": l.buy_now_price, "buyNowPrice": l.buy_now_price,
"startingBid": l.start_price, "startingBid": l.start_price,
"currentBid": current_bid, "currentBid": current_bid,
"bidState": bid_state, "bidState": bid_state,
"expires": 3600, "expires": expires,
"sellerName": l.owner.clone().unwrap_or_else(|| "EASFC".to_string()), // 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, "sellerEstablished": 1,
"watched": false, "watched": false,
"coinsProcessed": 0, "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 /// 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 /// 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 /// 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) off_runtime(|| econ.balance()).unwrap_or(0)
} }
/// Maps a FIFA wire `resourceId` to the authoritative Core `card_id` a synthetic /// A FutISStart POST resolved to a persistable listing — all owned/`Send` data,
/// buy mints. Backed by the FIFA17 catalog reverse index; unknown → `None` /// so it can cross the async persist boundary. Built by [`resolve_market_list`]
/// (fail closed, never fabricated). Core stays unaware of FIFA resource ids. /// on the caller's thread from Core inventory (the request body carries only the
pub trait MarketCardResolver: Send + Sync { /// wire item id, never the resourceId or Core card_id).
fn card_id_for_resource(&self, resource_id: i64) -> Option<String>; 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). /// `/auctionhouse` — search (GET), list-for-sale (POST FutISStart), relist (PUT).
/// ///
/// * GET returns the durable active auctions plus the FutGetAuctionCount ints, /// * GET returns the durable active auctions plus the FutGetAuctionCount ints,
/// in the oracle's `_market_body` shape. /// in the oracle's `_market_body` shape.
/// * POST lists a club item: resolve its wire `resourceId` to the authoritative /// * POST persists the pre-resolved listing (see [`resolve_market_list`]) and
/// Core `card_id`, persist both, and return `{"id": tradeId}`. An unmappable /// returns `{"id": tradeId}`; an unresolved item acks a fresh id, persisting
/// resource fails closed (persists nothing). /// nothing.
/// * PUT (relist-all) is an ack `{}`. /// * PUT (relist-all) is an ack `{}`.
pub async fn handle_market_list( pub async fn handle_market_list(
method: &str, method: &str,
body: &[u8], resolved: Option<ResolvedListing>,
econ: &dyn CoreEconomy, econ: &dyn CoreEconomy,
store: &MarketStore, store: &MarketStore,
mapper: &dyn MarketCardResolver,
) -> WireResponse { ) -> WireResponse {
match method { match method {
"POST" => { "POST" => {
let b = parse_body(body); let Some(r) = resolved else {
let item_data = b.get("itemData"); // No item, or the id did not resolve to an owned card: fresh-id ack.
let wire_item_id = item_data eprintln!(
.and_then(|d| d.get("id")) "utas-host owner=RUST route=market-list POST listed=false reason=unresolved"
.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 {
return ok_json(&json!({ "id": TRADE_ID_BASE })); return ok_json(&json!({ "id": TRADE_ID_BASE }));
}; };
// Trade-id space is offset from the wire item id, so each owned item // Trade-id space is offset from the wire item id, so each owned item
// maps to a unique, stable auction id (no modular wraparound). // 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 listing_id = trade_id.to_string();
let seller = b.get("sellerName").and_then(Value::as_str);
match store match store
.create_listing( .create_listing(
&listing_id, &listing_id,
&core_card_id, &r.card_id,
None, Some(&r.core_id),
Some(item_id), Some(r.item_id),
Some(resource_id), r.resource_id,
start, r.start,
buy_now, r.buy_now,
seller, r.seller.as_deref(),
r.item_json.as_deref(),
r.duration,
) )
.await .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" })), Err(_) => json_body(503, &json!({ "error": "market_store" })),
} }
} }
@@ -197,7 +354,10 @@ pub async fn handle_market_list(
Ok(l) => l, Ok(l) => l,
Err(_) => return json_body(503, &json!({ "error": "market_store" })), 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!({ ok_json(&json!({
"auctionInfo": auctions, "auctionInfo": auctions,
"credits": credits_or_zero(econ), "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 /// Query listings in a given `state` (e.g. the user's own sale pile is the
/// `active` set). Returns the oracle's tradePile shape. /// `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( pub async fn handle_market_query(
state: &str, state: &str,
econ: &dyn CoreEconomy, econ: &dyn CoreEconomy,
@@ -224,11 +388,43 @@ pub async fn handle_market_query(
Ok(l) => l, Ok(l) => l,
Err(_) => return json_body(503, &json!({ "error": "market_store" })), 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!({ ok_json(&json!({
"auctionInfo": auctions, "auctionInfo": auctions,
"credits": credits_or_zero(econ), "credits": credits_or_zero(econ),
"total": auctions.len(), "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!({})) 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 /// `/trade/<id>` — view (GET) or buy-now / bid (POST/PUT). Buy-now is the
/// synthetic-seller path: reserve (CAS) → Core `purchase_item` mint+debit → /// synthetic-seller path: reserve (CAS) → Core `purchase_item` mint+debit →
/// complete the sale; any Core failure rolls the reservation back. /// 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 // ---- ItemIdentityResolver + OwnedItemLookup doubles -------------------
/// the existing list tests keep their prior card-id semantics. A dedicated use openfut_adapter_fifa17::fut::item::{CoreOwnedItem, Fifa17Identity};
/// test covers the unknown-resource fail-closed path.
struct AllowAllResolver; /// Owned-inventory double: core id -> CoreOwnedItem.
impl MarketCardResolver for AllowAllResolver { struct FakeItems(HashMap<String, CoreOwnedItem>);
fn card_id_for_resource(&self, resource_id: i64) -> Option<String> { impl OwnedItemLookup for FakeItems {
Some(resource_id.to_string()) fn owned_item(&self, core_id: &str) -> Option<CoreOwnedItem> {
self.0.get(core_id).cloned()
} }
} }
/// Test resolver that maps nothing (every resourceId is unknown). /// Identity double: resolves every owned item to a fixed FIFA resourceId.
struct DenyAllResolver; struct FixedIdentity(u32);
impl MarketCardResolver for DenyAllResolver { impl ItemIdentityResolver for FixedIdentity {
fn card_id_for_resource(&self, _resource_id: i64) -> Option<String> { 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 None
} }
} }
@@ -551,7 +846,7 @@ mod tests {
async fn seed_listing(store: &MarketStore, id: &str, buy_now: i64) { async fn seed_listing(store: &MarketStore, id: &str, buy_now: i64) {
store 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 .await
.unwrap(); .unwrap();
} }
@@ -566,23 +861,47 @@ mod tests {
async fn list_post_persists_and_returns_trade_id() { async fn list_post_persists_and_returns_trade_id() {
let (store, _d) = store_at("post").await; let (store, _d) = store_at("post").await;
let econ = CountingEconomy::with_balance(10_000); 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 }); "startingBid": 300, "buyNowPrice": 2500 });
let resp = handle_market_list( let reverse = MapResolver::new(&[(100004617, "core-1")]);
"POST", 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(), body.to_string().as_bytes(),
&econ, &reverse,
&store, &ident,
&AllowAllResolver, &items,
) &NoEntities,
.await; );
let resp = handle_market_list("POST", resolved, &econ, &store).await;
assert_eq!(resp.status, 200); assert_eq!(resp.status, 200);
let trade_id = parse(&resp)["id"].as_i64().unwrap(); let trade_id = parse(&resp)["id"].as_i64().unwrap();
assert_eq!(trade_id, TRADE_ID_BASE + 100004617); 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(); let listed = store.get_listing(&trade_id.to_string()).await.unwrap();
assert_eq!(listed.buy_now_price, 2500); 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); let b = parse(&browse);
assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1); assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1);
assert_eq!(b["credits"], 10_000); assert_eq!(b["credits"], 10_000);
@@ -593,27 +912,31 @@ mod tests {
async fn list_put_is_ack() { async fn list_put_is_ack() {
let (store, _d) = store_at("put").await; let (store, _d) = store_at("put").await;
let econ = CountingEconomy::with_balance(0); 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!(resp.status, 200);
assert_eq!(parse(&resp), json!({})); assert_eq!(parse(&resp), json!({}));
} }
#[tokio::test] #[tokio::test]
async fn list_unknown_resource_fails_closed_no_listing() { async fn list_unresolved_item_fails_closed_no_listing() {
// An unmappable wire resourceId must NOT create a listing (a synthetic buy // A wire id that does not resolve to an owned card must NOT create a listing
// would otherwise mint a card id Core cannot resolve). Acks neutrally. // (a synthetic buy would otherwise mint a card Core cannot resolve). Acks.
let (store, _d) = store_at("deny").await; let (store, _d) = store_at("deny").await;
let econ = CountingEconomy::with_balance(10_000); 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 }); "startingBid": 300, "buyNowPrice": 2500 });
let resp = handle_market_list( let reverse = MapResolver::new(&[]); // maps nothing -> unresolved
"POST", let items = FakeItems(HashMap::new());
let ident = FixedIdentity(0);
let resolved = resolve_market_list(
body.to_string().as_bytes(), body.to_string().as_bytes(),
&econ, &reverse,
&store, &ident,
&DenyAllResolver, &items,
) &NoEntities,
.await; );
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!(resp.status, 200);
assert_eq!(parse(&resp)["id"].as_i64().unwrap(), TRADE_ID_BASE); assert_eq!(parse(&resp)["id"].as_i64().unwrap(), TRADE_ID_BASE);
// Nothing persisted at the would-be trade id: not buyable. // 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 store = MarketStore::open(db.path()).await.unwrap();
let econ = CountingEconomy::with_balance(10_000); let econ = CountingEconomy::with_balance(10_000);
// A resolver that maps resourceId 20801 -> Core card_id "card_pl_042". let body = json!({ "itemData": { "id": 100004900 },
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 },
"startingBid": 300, "buyNowPrice": 2500 }); "startingBid": 300, "buyNowPrice": 2500 });
let resp = handle_market_list( let reverse = MapResolver::new(&[(100004900, "core-9")]);
"POST", 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(), body.to_string().as_bytes(),
&econ, &reverse,
&store, &ident,
&FixedResolver, &items,
) &NoEntities,
.await; );
let resp = handle_market_list("POST", resolved, &econ, &store).await;
trade_id = parse(&resp)["id"].as_i64().unwrap(); trade_id = parse(&resp)["id"].as_i64().unwrap();
} }
// Reopen from the same file: both identities survive. // Reopen from the same file: both identities survive.
@@ -676,6 +999,166 @@ mod tests {
assert_eq!(b["credits"], 50); 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] #[tokio::test]
async fn buy_now_debits_mints_and_closes() { async fn buy_now_debits_mints_and_closes() {
let (store, _d) = store_at("buy").await; let (store, _d) = store_at("buy").await;
+229 -5
View File
@@ -146,6 +146,52 @@ pub struct Listing {
pub state: String, pub state: String,
/// Creation time, unix-epoch milliseconds as a string (sortable). /// Creation time, unix-epoch milliseconds as a string (sortable).
pub created_at: String, 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 ( 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, buy_now_price INTEGER NOT NULL,
owner TEXT, owner TEXT,
state TEXT NOT NULL CHECK (state IN ('active','reserved','sold','cancelled')), 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 { fn now_millis() -> String {
@@ -182,6 +230,8 @@ fn row_to_listing(row: &sqlx::sqlite::SqliteRow) -> Listing {
owner: row.get("owner"), owner: row.get("owner"),
state: row.get("state"), state: row.get("state"),
created_at: row.get("created_at"), 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) .execute(&pool)
.await .await
.map_err(db)?; .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 { Ok(MarketStore {
pool, pool,
fault: StoreFault::default(), fault: StoreFault::default(),
@@ -249,6 +321,11 @@ impl MarketStore {
start_price: i64, start_price: i64,
buy_now_price: i64, buy_now_price: i64,
owner: Option<&str>, 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> { ) -> Result<Listing, MarketError> {
let created_at = now_millis(); let created_at = now_millis();
let mut conn = self.pool.acquire().await.map_err(db)?; let mut conn = self.pool.acquire().await.map_err(db)?;
@@ -258,8 +335,9 @@ impl MarketStore {
.map_err(db)?; .map_err(db)?;
let res = sqlx::query( let res = sqlx::query(
"INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, \ "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) \ wire_resource_id, start_price, buy_now_price, owner, state, created_at, item_json, \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?)", duration_secs) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)",
) )
.bind(listing_id) .bind(listing_id)
.bind(card_id) .bind(card_id)
@@ -270,6 +348,8 @@ impl MarketStore {
.bind(buy_now_price) .bind(buy_now_price)
.bind(owner) .bind(owner)
.bind(&created_at) .bind(&created_at)
.bind(item_json)
.bind(duration_secs)
.execute(&mut *conn) .execute(&mut *conn)
.await; .await;
match res { match res {
@@ -289,6 +369,8 @@ impl MarketStore {
owner: owner.map(str::to_string), owner: owner.map(str::to_string),
state: "active".to_string(), state: "active".to_string(),
created_at, created_at,
item_json: item_json.map(str::to_string),
duration_secs,
}) })
} }
Err(e) => { Err(e) => {
@@ -463,6 +545,53 @@ impl MarketStore {
} }
outcome 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)] #[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) { async fn temp_store() -> (MarketStore, TempDb) {
let db = TempDb::new(); let db = TempDb::new();
let store = MarketStore::open(db.path()).await.unwrap(); let store = MarketStore::open(db.path()).await.unwrap();
@@ -502,7 +717,7 @@ mod tests {
async fn seed(store: &MarketStore, id: &str) -> Listing { async fn seed(store: &MarketStore, id: &str) -> Listing {
store 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 .await
.unwrap() .unwrap()
} }
@@ -533,7 +748,7 @@ mod tests {
seed(&store, "900000001").await; seed(&store, "900000001").await;
assert!(matches!( assert!(matches!(
store 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, .await,
Err(MarketError::Conflict) Err(MarketError::Conflict)
)); ));
@@ -597,6 +812,8 @@ mod tests {
900, 900,
2500, 2500,
Some("alice"), Some("alice"),
None,
None,
) )
.await .await
.unwrap(); .unwrap();
@@ -668,6 +885,8 @@ mod tests {
900, 900,
2500, 2500,
Some("alice"), Some("alice"),
Some(r#"{"rating":84}"#),
None,
) )
.await .await
.unwrap(); .unwrap();
@@ -683,5 +902,10 @@ mod tests {
assert_eq!(got.core_item_id.as_deref(), Some("core-7")); assert_eq!(got.core_item_id.as_deref(), Some("core-7"));
assert_eq!(got.wire_item_id, Some(100004617)); assert_eq!(got.wire_item_id, Some(100004617));
assert_eq!(got.owner.as_deref(), Some("alice")); 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. /// exactly one debit; exactly one mint.
fn case_d_two_market_buyers(h: &Harness) -> String { fn case_d_two_market_buyers(h: &Harness) -> String {
let mut wins = 0u32; 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); set_balance(&h.client, 50_000);
let item_id = 500_000 + i as i64; // unique listing per iteration
let list = h let list = h
.server .server
.try_handle_economy( .try_handle_economy(
"POST", "POST",
"/ut/game/fifa17/auctionhouse", "/ut/game/fifa17/auctionhouse",
&[], &[],
format!( format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
r#"{{"itemData":{{"id":{item_id},"resourceId":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
h.sample_resource
)
.as_bytes(), .as_bytes(),
None, None,
) )
@@ -92,6 +92,7 @@
use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog; use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog;
use openfut_adapter_fifa17::fut::entities::Fifa17Entities; use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
use openfut_adapter_fifa17::fut::store_session::{SessionStore, StoreMode, SENTINEL_PACK_ID}; 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_identity::JsonIdentityStore;
use openfut_utas_host::async_bridge::AsyncBridge; use openfut_utas_host::async_bridge::AsyncBridge;
use openfut_utas_host::market_store::MarketStore; 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) { fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
wait_ready(core_base); wait_ready(core_base);
let http = reqwest::blocking::Client::new(); 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. ── // ── Fixture alignment: both sides own exactly one pack-70 entitlement. ──
// Oracle: fresh profile already owns pack 70. Core: grant the "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, None,
); );
let o_trade_id = o_list["id"].as_i64().expect("oracle trade id"); 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"); let r_trade_id = r_list["id"].as_i64().expect("rust trade id");
assert_eq!(o_ls, 200); assert_eq!(o_ls, 200);
assert_eq!(r_ls, 200, "market list status parity"); 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", r_tp["auctionInfo"][0]["tradeState"], "active",
"rust listing 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")); matrix.push(("market query tradePile", "PARITY"));
// ── OP 13: market buy (POST /trade/<id>) — DIFFERENT-BY-DESIGN ───────── // ── 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" "oracle cancelled listing gone from tradePile"
); );
// Rust: list a fresh item, cancel it, then a buy is a 0-delta empty auction. // 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_cancel_id = clist["id"].as_i64().unwrap();
let (r_cs, _) = rust( let (r_cs, _) = rust(
&server, &server,
+8 -17
View File
@@ -257,7 +257,6 @@ struct FailHarness {
bridge: Arc<AsyncBridge>, bridge: Arc<AsyncBridge>,
core: Arc<dyn CoreAccess>, core: Arc<dyn CoreAccess>,
entities: Arc<Fifa17Entities>, entities: Arc<Fifa17Entities>,
sample_resource: i64,
} }
fn catalog_from_core(core: &dyn CoreAccess) -> Fifa17CardCatalog { fn catalog_from_core(core: &dyn CoreAccess) -> Fifa17CardCatalog {
@@ -341,7 +340,6 @@ fn build_fail_harness(base: &str, dir: &std::path::Path) -> FailHarness {
bridge, bridge,
core, core,
entities, 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). /// MARKET RESERVE failure → no debit, no grant, listing stays legal (active).
fn case_market_reserve_failure(h: &FailHarness) -> String { 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); set_balance(&h.client, 50_000);
let item_id = 700_001i64;
let list = h.dispatch( let list = h.dispatch(
"POST", "POST",
"/ut/game/fifa17/auctionhouse", "/ut/game/fifa17/auctionhouse",
format!( format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
r#"{{"itemData":{{"id":{item_id},"resourceId":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
h.sample_resource
)
.as_bytes(), .as_bytes(),
); );
let trade_id = bj(&list)["id"].as_i64().expect("trade id"); 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 /// MARKET Core purchase_item failure AFTER reserve → reservation rolls back to
/// active, no debit, no mint. /// active, no debit, no mint.
fn case_market_purchase_failure(h: &FailHarness) -> String { fn case_market_purchase_failure(h: &FailHarness) -> String {
let (item_id, _core) = h.mint_one();
set_balance(&h.client, 50_000); set_balance(&h.client, 50_000);
let item_id = 700_002i64;
let list = h.dispatch( let list = h.dispatch(
"POST", "POST",
"/ut/game/fifa17/auctionhouse", "/ut/game/fifa17/auctionhouse",
format!( format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
r#"{{"itemData":{{"id":{item_id},"resourceId":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
h.sample_resource
)
.as_bytes(), .as_bytes(),
); );
let trade_id = bj(&list)["id"].as_i64().expect("trade id"); 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, /// active), so no further `active -> reserved` CAS can succeed → not buyable,
/// with exactly one debit + one mint. Returns ("SAFE"|"E3", detail). /// with exactly one debit + one mint. Returns ("SAFE"|"E3", detail).
fn case_market_complete_sale_failure(h: &FailHarness) -> (String, String) { fn case_market_complete_sale_failure(h: &FailHarness) -> (String, String) {
let (item_id, _core) = h.mint_one();
set_balance(&h.client, 50_000); set_balance(&h.client, 50_000);
let item_id = 700_003i64;
let list = h.dispatch( let list = h.dispatch(
"POST", "POST",
"/ut/game/fifa17/auctionhouse", "/ut/game/fifa17/auctionhouse",
format!( format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
r#"{{"itemData":{{"id":{item_id},"resourceId":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
h.sample_resource
)
.as_bytes(), .as_bytes(),
); );
let trade_id = bj(&list)["id"].as_i64().expect("trade id"); let trade_id = bj(&list)["id"].as_i64().expect("trade id");
+16 -4
View File
@@ -350,7 +350,7 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
wait_ready(base); wait_ready(base);
// Core is seeded (start_core_seeded): a fifa17 profile with 100k coins + one // Core is seeded (start_core_seeded): a fifa17 profile with 100k coins + one
// owned instance per definition. No /auth/local — the profile already exists. // 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"); build_econ_server(base, dir, "http://127.0.0.1:9");
let start = client.balance().unwrap(); let start = client.balance().unwrap();
assert!(start >= 5000, "seeded dev balance present ({start})"); 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 -> // 5) MARKET buy-now (async handlers via the bridge): list -> query -> buy ->
// query -> second buy fails, exactly one debit + one sale. // 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 let list = server
.try_handle_economy( .try_handle_economy(
"POST", "POST",
"/ut/game/fifa17/auctionhouse", "/ut/game/fifa17/auctionhouse",
&[], &[],
format!( format!(
r#"{{"itemData":{{"id":777,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"# r#"{{"itemData":{{"id":{list_wire}}},"buyNowPrice":1000,"startingBid":500}}"#
) )
.as_bytes(), .as_bytes(),
None, None,
@@ -504,13 +508,14 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
); );
// 6) MARKET cancel: a cancelled listing cannot be bought. // 6) MARKET cancel: a cancelled listing cannot be bought.
let cancel_wire = items[3]["id"].as_i64().unwrap();
let clist = server let clist = server
.try_handle_economy( .try_handle_economy(
"POST", "POST",
"/ut/game/fifa17/auctionhouse", "/ut/game/fifa17/auctionhouse",
&[], &[],
format!( format!(
r#"{{"itemData":{{"id":888,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"# r#"{{"itemData":{{"id":{cancel_wire}}},"buyNowPrice":1000,"startingBid":500}}"#
) )
.as_bytes(), .as_bytes(),
None, None,
@@ -759,6 +764,7 @@ fn from_config(base: &str, dir: &std::path::Path) -> openfut_utas_host::config::
persona_id: 33_068_179, persona_id: 33_068_179,
market_db_path: dir.join("market.db").to_string_lossy().into_owned(), market_db_path: dir.join("market.db").to_string_lossy().into_owned(),
pile_db_path: dir.join("pile.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"); .expect("buy routed");
assert_eq!(buy.status, 200); 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!( assert_eq!(
client.balance().unwrap(), client.balance().unwrap(),
start - 400, start - 400,
@@ -807,7 +818,8 @@ fn exercise_from_config(base: &str, dir: &std::path::Path) -> i64 {
"POST", "POST",
"/ut/game/fifa17/auctionhouse", "/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, None,
) )
.expect("list routed"); .expect("list routed");
+214 -5
View File
@@ -13,10 +13,10 @@ use openfut_adapter_fifa17::fut::club_response::{CoreOwnedItem, ItemIdentityReso
use openfut_adapter_fifa17::fut::entities::Fifa17Entities; use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
use openfut_identity::JsonIdentityStore; use openfut_identity::JsonIdentityStore;
use openfut_utas_host::{ use openfut_utas_host::{
classify, handle_put_squad, handle_squad_active, handle_squad_list, handle_user_mass_info, classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list,
read_request, CoreAccess, CoreError, CoreExtState, CorePage, CoreReplaceRequest, handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState, CorePage,
CoreReplaceResult, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, CoreReplaceRequest, CoreReplaceResult, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver,
PassClient, Route, Server, SquadDeps, HttpCoreClient, PassClient, Route, Server, SquadDeps,
}; };
use parking_lot::Mutex; use parking_lot::Mutex;
use serde_json::Value; 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 ───────────────────────────────────────────────── // ── /club served from Core ─────────────────────────────────────────────────
#[test] #[test]
@@ -795,7 +875,8 @@ fn classify_squad_and_usermassinfo_routes() {
Route::UserMassInfo Route::UserMassInfo
); );
// GET /squad/active is now Core-backed (SquadActive). A squad PUT is never a // 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!( assert_eq!(
classify("GET", "/ut/game/fifa17/squad/active"), classify("GET", "/ut/game/fifa17/squad/active"),
Route::SquadActive Route::SquadActive
@@ -806,6 +887,10 @@ fn classify_squad_and_usermassinfo_routes() {
); );
assert_eq!( assert_eq!(
classify("GET", "/ut/game/fifa17/squad/0"), classify("GET", "/ut/game/fifa17/squad/0"),
Route::SquadActive
);
assert_eq!(
classify("GET", "/ut/game/fifa17/squad/5"),
Route::Passthrough Route::Passthrough
); );
assert_eq!( assert_eq!(
@@ -1347,3 +1432,127 @@ fn duplicate_definition_instances_stay_distinct_through_host() {
assert_eq!(p["itemData"]["resourceId"], 20801, "shared asset id"); 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");
}
+147
View File
@@ -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())