host: ViewCards must return OWNED INSTANCES, not definition placeholders
GET ut/%s/item is FutViewCards and its ids are OWNED INSTANCE ids - the client
builds the query as ?idList=%lld (CardsDLL .rdata 0x220080) from ids it already
holds. It was being answered with the definition body, which echoes the queried
id straight back. Asked about the active home kit, instance 100004874, the server
replied:
resourceId 100004874, cardsubtypeid 0, itemType "player", itemState "free"
i.e. "your active home kit is a free player". No kit can ever be seen as active
through that. Real players were equally wrong: instance 100000003 came back as
resourceId 100000003 instead of the actual card 200389 rating 87.
This is on the active-kit path, and the evidence for that is the client's own UI.
KitAssignmentPopup.BIG (exported from Frosty today) decompiles to
external.ion_fut.components.KitAssignmentPopup and contains OSDKCards_ViewCards,
OSDKCards_ActivateCard, mHomeKitID/mAwayKitID/mSourceKitID, and the search states
SEARCH_STATE_ACTIVE_HOME_KIT / SEARCH_STATE_ACTIVE_AWAY_KIT. Those states can
only come from the itemState this route returns.
Route::ViewCards is now distinct from Route::ItemDefs. Owned instances are shaped
by the SAME projector /club uses, so there is one wire dialect and no drift; ids
that are not owned instances still fall back to the definition placeholder, and
the empty query still answers {"itemData": []}, preserving oracle parity for the
definition-style callers (item/resource, defid).
Verified on staging:
?idList=100004874,100004873 -> resourceId 6300006/6400003, cardsubtypeid 9,
itemType kit, itemState activeHomeKit/activeAwayKit, teamid 21, cat 2/3
?idList=100000003 -> resourceId 200389, rating 87 (the real card)
?idList=999999999 -> definition fallback
no query -> {"itemData": []}
item/resource? and defid? -> unchanged
cargo test -p openfut-utas-host: 190 passed, 0 failed. clippy -D warnings clean.
This commit is contained in:
+120
-16
@@ -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/<id>`, 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<i64> = ids.iter().copied().collect();
|
||||
let mut owned: Vec<Value> = 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<CoreOwnedItem> = 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<i64> = owned
|
||||
.iter()
|
||||
.filter_map(|it| it.get("id").and_then(Value::as_i64))
|
||||
.collect();
|
||||
let missing: Vec<i64> = 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/<resourceId>` — apply one consumable to one owned
|
||||
/// target (`ApplyCardByRes`, task id `0x0e`), live-captured 2026-08-21 as
|
||||
/// `{"apply":[{"id":<target>}]}`.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user