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.
This commit is contained in:
funman300
2026-08-17 18:10:32 +00:00
parent aa2abc2772
commit f2c4927ea6
3 changed files with 31 additions and 23 deletions
+2 -2
View File
@@ -57,7 +57,7 @@ load-bearing and were each a live defect:
| GET /userMassInfo | R | FULL Rust envelope (userInfo+squad+settings+pileSizeClientData); no Python. coins from Core, squad == /squad/active | | 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 {} | | GET/PUT /clientdata/<key> | R | host ClientDataStore (JSON-persisted); PUT acks {}, GET returns blob or {} |
| capability (/openfut/fifa17/capability) | R | -> Bound (CleanV1) | | capability (/openfut/fifa17/capability) | R | -> Bound (CleanV1) |
| GET /club, /club/* readers | R | Core-backed collection (dropped_no_asset=0). Cards in the **transfer pile are excluded** — a listed card has left the club. Pagination then runs over the club-visible set (Core cannot filter on host-owned pile state, so letting it paginate would yield short pages); with nothing hidden the fast Core-paginated path is unchanged. Only an EXPLICIT non-club pile hides a card, so no-pile-row items default to the club. | | 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 /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/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 /user | R | `{"userInfo": …}` — same userInfo builder as userMassInfo (shared); squad rating is Core-authoritative (DIFFERENT-BY-DESIGN vs Python's stale value) |
@@ -65,7 +65,7 @@ load-bearing and were each a live defect:
| GET /leaderboards/options | R | {} | | GET /leaderboards/options | R | {} |
| PUT /match/reset | R | {} | | PUT /match/reset | R | {} |
| GET /phishing/trusteddevice | R | security-question stateless ack | | GET /phishing/trusteddevice | R | security-question stateless ack |
| GET /hub | R | Core-derived counts; `clubPlayers` excludes transfer-pile cards (1966 → 1961 with 5 listed/moved), `auctionCount`/`tradePile` from the durable market store | | 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 /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 /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 /watchList (+ PUT/POST/DELETE) | R | empty watch list + authoritative Core credits; add/remove is a no-op ack (oracle persists none) |
+26 -18
View File
@@ -1118,10 +1118,9 @@ pub struct ClubDeps<'a> {
pub core: &'a dyn CoreAccess, pub core: &'a dyn CoreAccess,
pub entities: &'a Fifa17Entities, pub entities: &'a Fifa17Entities,
pub assets: &'a (dyn ItemIdentityResolver + Send + Sync), pub assets: &'a (dyn ItemIdentityResolver + Send + Sync),
/// Core owned-instance ids that are NOT in the club view — currently the /// Core owned-instance ids that are NOT in the club view — the cards with an
/// transfer (`trade`) pile. In FIFA a card on the transfer list has LEFT the /// ACTIVE transfer-market listing. In FIFA a listed card has LEFT the club, so
/// club, so it must not also appear here. Empty = show everything Core owns /// it must not also appear here. Empty = show everything Core owns.
/// (an item with no recorded pile defaults to the club).
pub hidden: &'a std::collections::HashSet<String>, pub hidden: &'a std::collections::HashSet<String>,
} }
@@ -1200,7 +1199,7 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog)
// Host-side filters Core cannot express: // Host-side filters Core cannot express:
// * "Special" (rare=SP): rareflag lives in the FIFA catalog, not Core. // * "Special" (rare=SP): rareflag lives in the FIFA catalog, not Core.
// * pile exclusion: the transfer pile is host-owned state. // * listed-card exclusion: transfer-market listings are host-owned state.
// Either way Core must NOT paginate — it would paginate the unfiltered set // Either way Core must NOT paginate — it would paginate the unfiltered set
// and return short pages. So fetch everything matching the OTHER filters, // and return short pages. So fetch everything matching the OTHER filters,
// exclude/shape locally, then paginate the filtered set here. When neither // exclude/shape locally, then paginate the filtered set here. When neither
@@ -2761,22 +2760,31 @@ impl Server {
json_status(status, &body) json_status(status, &body)
} }
/// Core owned-instance ids that are NOT part of the CLUB view: currently the /// Core owned-instance ids that are NOT part of the CLUB view: the cards with
/// transfer (`trade`) pile. In FIFA a listed card has LEFT the club, so it must /// an ACTIVE transfer-market listing. In FIFA a listed card has left the club,
/// not appear in `/club` or the hub's `clubPlayers` tally while it sits on the /// so it must not appear in `/club` or the hub's `clubPlayers` tally while it
/// transfer list. Only an EXPLICIT non-club pile hides an item — an item with /// is on sale.
/// no recorded pile defaults to the club, so an imported profile is unaffected. ///
/// Empty when no economy services are wired (bare test server), and a pile-store /// Keyed on the ACTIVE LISTING, deliberately NOT on the `trade` pile. The pile
/// can hold cards with no listing (a bare "Place on Transfer Market" move, or a
/// listing that was cancelled/sold), and `/tradePile` renders ONLY active
/// listings — so hiding the whole pile would make those cards invisible in BOTH
/// views. Keying on the listing makes visibility 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).
///
/// Empty when no economy services are wired (bare test server); a market-store
/// read failure degrades to showing everything (never hides inventory silently). /// read failure degrades to showing everything (never hides inventory silently).
fn club_hidden_ids(&self) -> std::collections::HashSet<String> { fn club_hidden_ids(&self) -> std::collections::HashSet<String> {
let Some(svc) = self.economy.as_ref() else { let Some(svc) = self.economy.as_ref() else {
return std::collections::HashSet::new(); return std::collections::HashSet::new();
}; };
let (bridge, piles) = (svc.bridge.clone(), svc.piles.clone()); let (bridge, market) = (svc.bridge.clone(), svc.market.clone());
match bridge.block_on(async move { piles.list_by_pile("trade").await }) { match bridge.block_on(async move { market.query_listings("active").await }) {
Ok(ids) => ids.into_iter().collect(), Ok(listings) => listings.into_iter().filter_map(|l| l.core_item_id).collect(),
Err(e) => { Err(e) => {
eprintln!("utas-host WARN pile read failed (club shows all): {e}"); eprintln!("utas-host WARN market read failed (club shows all): {e}");
std::collections::HashSet::new() std::collections::HashSet::new()
} }
} }
@@ -2784,9 +2792,9 @@ impl Server {
/// `GET …/hub` — the FUT hub tile counts, owned in Rust (no Python). Derived /// `GET …/hub` — the FUT hub tile counts, owned in Rust (no Python). Derived
/// from authoritative state: `clubPlayers` is the count of owned PLAYER cards /// from authoritative state: `clubPlayers` is the count of owned PLAYER cards
/// in Core that are in the club (transfer-pile cards excluded — they have left /// in Core that are in the club (actively-listed cards excluded — they have
/// the club), and the auction / tradePile counts are the user's active listings /// left the club), and the auction / tradePile counts are the user's active
/// in the durable market store. `clubPlayers` may be lower than the Python /// listings in the durable market store. `clubPlayers` may be lower than the Python
/// oracle's profile count by exactly the deferred (unnameable Legend) /// oracle's profile count by exactly the deferred (unnameable Legend)
/// instances — DIFFERENT-BY-DESIGN, since deferred cards are not owned in /// instances — DIFFERENT-BY-DESIGN, since deferred cards are not owned in
/// Core. Fail-closed on Core error (503); a market-store read failure degrades /// Core. Fail-closed on Core error (503); a market-store read failure degrades
+3 -3
View File
@@ -295,11 +295,11 @@ fn resolver_for(cards: &[(&str, u32)]) -> Arc<Fifa17IdentityResolver> {
Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store))) Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)))
} }
/// A card on the transfer list has LEFT the club: `/club` must not show it, and /// 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, /// pagination must run over the CLUB-VISIBLE set (never Core's unfiltered page,
/// which would hand back short pages). /// which would hand back short pages).
#[test] #[test]
fn club_excludes_transfer_pile_items_and_paginates_the_visible_set() { fn club_excludes_listed_items_and_paginates_the_visible_set() {
let core = Arc::new(FakeCore::new( let core = Arc::new(FakeCore::new(
vec![ vec![
item("oc1", "card_a", 86, "CDM", "Argentina", "Premier League", "Chelsea"), item("oc1", "card_a", 86, "CDM", "Argentina", "Premier League", "Chelsea"),
@@ -311,7 +311,7 @@ fn club_excludes_transfer_pile_items_and_paginates_the_visible_set() {
let resolver = resolver_for(&[("card_a", 20801), ("card_b", 20802), ("card_c", 20803)]); let resolver = resolver_for(&[("card_a", 20801), ("card_b", 20802), ("card_c", 20803)]);
let ents = entities(); let ents = entities();
// oc2 is listed on the transfer market. // oc2 has an active transfer-market listing.
let hidden: std::collections::HashSet<String> = ["oc2".to_string()].into_iter().collect(); let hidden: std::collections::HashSet<String> = ["oc2".to_string()].into_iter().collect();
let deps = ClubDeps { let deps = ClubDeps {
core: core.as_ref(), core: core.as_ref(),