diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 8d47a97..79a8af3 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -188,6 +188,12 @@ pub enum Route { /// the oracle's `defs_route`/`item_def`. The client renders the real card from /// its LOCAL DB, so a valid-shaped placeholder is exact parity. ItemDefs, + /// `GET …/item` — FutViewCards. DISTINCT from [`Route::ItemDefs`]: the client + /// builds `?idList=%lld` from OWNED INSTANCE ids and expects the owned items + /// back, carrying their real `resourceId`, `cardsubtypeid` and `itemState`. + /// Answering it with definition placeholders tells the client its active kit + /// is a free player. See [`UtasHost::handle_view_cards`]. + ViewCards, /// `GET …/marketdata` and `…/marketdata/pricelimits` — suggested pricing. /// `/pricelimits` MUST be a bare ARRAY (one `{defId,minPrice,maxPrice}` per /// queried defId); plain `/marketdata` MUST be an OBJECT `{minPrice,maxPrice}`. @@ -297,27 +303,25 @@ pub fn classify(method: &str, path: &str) -> Route { } Some(t) if get && (t == "defid" || t.starts_with("defid?")) => Route::ItemDefs, // `GET ut/%s/item` is FutViewCards (deser `0x1801293d0`, top-level - // `itemData` via the shared card element `0x18013fe00`). The oracle - // answers it with `defs_route` — the SAME handler as `item/resource` and - // `defid`: parse every integer out of the query (`idList=a,b,c`, - // `definitionId=`, `resourceId=`) and return one definition per id, or - // `{"itemData": []}` when the query carries none - // (`tools/utas_server.py:1103`). Claiming it is byte-identical parity. + // `itemData` via the shared card element `0x18013fe00`). It is NOT the + // definition lookup, and answering it as one is a real defect — see + // [`Self::handle_view_cards`]. The client builds it as `?idList=%lld` + // (CardsDLL `.rdata` 0x220080) carrying OWNED INSTANCE ids. // - // It was unclaimed, so it fell through to the Python upstream. That is - // invisible in production, where the oracle answers, and shows up only on - // staging as a 502 — the same dead-route class already found four times + // It was unclaimed entirely until 2026-08-23, so it fell through to the + // Python upstream: invisible in production where the oracle answers, and a + // 502 on staging — the same dead-route class already found four times // (season/list, watchList, the handle_static_ack tails, tournament/user). // // The `?` forms are not decoration: `ut_tail` does NOT strip the query, so - // a bare equality arm silently misses every real request, which is exactly - // how this route stayed unclaimed. The oracle's own pattern is - // `item(\?|$)` (`tools/utas_server.py:1419`) and this mirrors it. + // a bare equality arm silently misses every real request while passing a + // no-query unit test. That is how this stayed unclaimed. The oracle's own + // pattern is `item(\?|$)` (`tools/utas_server.py:1419`). // // MUST stay below `item/resource` (matched first) and must not swallow // `item/`, which is DELETE-only Quick Sell, nor PUT `item`, which is // FutMoveCard on the economy path. - Some(t) if get && (t == "item" || t.starts_with("item?")) => Route::ItemDefs, + Some(t) if get && (t == "item" || t.starts_with("item?")) => Route::ViewCards, Some(t) if get && (t == "marketdata" || t.starts_with("marketdata/")) => Route::MarketData, _ => Route::Passthrough, } @@ -4351,6 +4355,7 @@ impl Server { Route::FeatureOffEmpty => self.handle_feature_off_empty(path), Route::Season => self.handle_season(path), Route::ItemDefs => self.handle_item_defs(target), + Route::ViewCards => self.handle_view_cards(target), Route::MarketData => self.handle_marketdata(path, target), Route::SecurityQuestion => self.handle_security_question(method, target, headers), Route::Passthrough => self.passthrough(method, target, headers, body), @@ -4983,6 +4988,105 @@ impl Server { json_status(200, &non_economy::item_defs_body(&ids)) } + /// `GET …/item?idList=…` — FutViewCards. + /// + /// The ids here are OWNED INSTANCE ids, not definition ids. The client builds + /// the query as `?idList=%lld` (CardsDLL `.rdata` `0x220080`) from ids it + /// already holds, and reads the returned items' real fields back. + /// + /// This is on the active-kit path. The FUT "Assign Kit" popup + /// (`external.ion_fut.components.KitAssignmentPopup`, recovered from + /// `KitAssignmentPopup.BIG`) drives `OSDKCards_ViewCards` and + /// `OSDKCards_ActivateCard`, tracks `mHomeKitID`/`mAwayKitID`, and filters its + /// local card inventory by `SEARCH_STATE_ACTIVE_HOME_KIT` / + /// `SEARCH_STATE_ACTIVE_AWAY_KIT`. Those states can only come from the + /// `itemState` this route returns. + /// + /// THE DEFECT THIS FIXES: answering with the definition body + /// ([`Self::handle_item_defs`]) echoes the queried id back as `resourceId` and + /// emits `cardsubtypeid: 0`, `itemType: "player"`, `itemState: "free"`. Asked + /// about the active home kit (instance 100004874) the server replied that it + /// was a free player — so no kit can ever be seen as active. Measured on + /// staging 2026-08-23. + /// + /// Owned instances are shaped by the SAME projector `/club` uses, so a kit + /// carries `resourceId` 6300006, `cardsubtypeid` 9, `itemState` + /// `activeHomeKit`, `teamid`, `category` and `year` exactly as it does there — + /// one shaping, no second wire dialect to drift. + /// + /// Ids that are NOT owned instances fall back to the definition placeholder, + /// preserving the oracle's behaviour for definition-style queries and for the + /// empty query (`{"itemData": []}`). + fn handle_view_cards(&self, target: &str) -> WireResponse { + let query = target.split_once('?').map(|(_, q)| q).unwrap_or(""); + let ids = extract_long_ints(query); + if ids.is_empty() { + eprintln!("utas-host owner=RUST route=view-cards count=0 owned=0 status=200"); + return json_status(200, &json!({ "itemData": [] })); + } + + // Shape the whole owned set once with the club projector, then select the + // requested instances from it. Selecting first is not possible: the wire + // instance id is assigned BY the projector, so there is nothing to match + // against until the items are shaped. + let wanted: std::collections::HashSet = ids.iter().copied().collect(); + let mut owned: Vec = Vec::new(); + if let Ok(page) = self.core.query_owned(&[]) { + let hidden = self.club_hidden_ids(); + let active_kits = self.core.get_active_kits().unwrap_or_default(); + let visible: Vec = page + .items + .into_iter() + .filter(|item| !hidden.contains(&item.owned_card_id)) + .collect(); + let (body, _) = shape_club_response_with_kits( + &visible, + self.entities.as_ref(), + self.resolver.as_ref(), + ActiveKitAssignments { + home: active_kits.home_owned_card_id.as_deref(), + away: active_kits.away_owned_card_id.as_deref(), + }, + ); + owned = body + .get("itemData") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter(|it| { + it.get("id") + .and_then(Value::as_i64) + .is_some_and(|id| wanted.contains(&id)) + }) + .cloned() + .collect() + }) + .unwrap_or_default(); + } + + let found: std::collections::HashSet = owned + .iter() + .filter_map(|it| it.get("id").and_then(Value::as_i64)) + .collect(); + let missing: Vec = ids.into_iter().filter(|id| !found.contains(id)).collect(); + let mut items = owned; + if !missing.is_empty() { + if let Some(defs) = non_economy::item_defs_body(&missing) + .get("itemData") + .and_then(Value::as_array) + { + items.extend(defs.iter().cloned()); + } + } + eprintln!( + "utas-host owner=RUST route=view-cards count={} owned={} defs={} status=200", + items.len(), + found.len(), + missing.len() + ); + json_status(200, &json!({ "itemData": items })) + } + /// `POST …/item/resource/` — apply one consumable to one owned /// target (`ApplyCardByRes`, task id `0x0e`), live-captured 2026-08-21 as /// `{"apply":[{"id":}]}`. @@ -7080,13 +7184,13 @@ mod tests { #[test] fn get_item_is_claimed_and_the_other_item_verbs_are_unaffected() { // FutViewCards, with and without the definition query the client builds. - assert_eq!(classify("GET", "/ut/game/fifa17/item"), Route::ItemDefs); + assert_eq!(classify("GET", "/ut/game/fifa17/item"), Route::ViewCards); assert_eq!( classify("GET", "/ut/game/fifa17/item?idList=100000003,100000004"), - Route::ItemDefs + Route::ViewCards ); // v2 sku form resolves identically. - assert_eq!(classify("GET", "/ut/v2/game/fifa17/item"), Route::ItemDefs); + assert_eq!(classify("GET", "/ut/v2/game/fifa17/item"), Route::ViewCards); // The more specific definition routes still win, and — the bug this test // exists for — they must survive a query string too. `ut_tail` does not // strip the query, so an equality-only arm misses every real request while