feat(host): serve owned non-player content from Core's ownership truth

Follows Core's kit designations becoming generic active-item slots: the host
reads `GET /club/active-items` (five always-present slots) instead of the
removed `/club/kits`.

Adds the consumables route and widens the club families to every content kind,
all resolved from Core ownership + the FIFA catalog. An item the client sees is
now an item Core actually owns.
This commit is contained in:
funman300
2026-08-21 19:49:59 +00:00
parent 6c7d0856b6
commit 802f0f580f
4 changed files with 781 additions and 41 deletions
+413
View File
@@ -2028,3 +2028,416 @@ fn watchlist_is_rust_owned_and_never_passed_through() {
);
}
}
// ── /club ?type= completeness, and the consumables route ────────────────────
/// A club holding one of everything the taxonomy can ask about: two players (a
/// forward and a defender), a manager, a GK coach, a fitness coach, two kits and
/// one consumable — the same mix the real profile has, at fixture scale.
fn mixed_club_catalog() -> &'static str {
"\"fifa17_20801\":{\"asset_id\":20801},\
\"fifa17_158023\":{\"asset_id\":158023},\
\"fifa17_1000509\":{\"asset_id\":1000509,\"kind\":\"staff\",\"subtype\":4,\
\"nation\":45,\"league_id\":53,\"team_id\":241},\
\"fifa17_9000081\":{\"asset_id\":9000081,\"kind\":\"staff\",\"subtype\":6},\
\"fifa17_3000083\":{\"asset_id\":3000083,\"kind\":\"staff\",\"subtype\":8},\
\"fifa17_6300006\":{\"asset_id\":6300006,\"kind\":\"kit\",\"subtype\":9,\
\"card_asset_id\":35,\"team_id\":21},\
\"fifa17_6400003\":{\"asset_id\":6400003,\"kind\":\"kit\",\"subtype\":9,\
\"card_asset_id\":35,\"team_id\":21},\
\"fifa17_5003012\":{\"asset_id\":5003012,\"kind\":\"consumable\",\"subtype\":54,\
\"card_asset_id\":3,\"rareflag\":0,\"rating\":85,\"amount\":15}"
}
fn mixed_club_items() -> Vec<CoreOwnedItem> {
vec![
item(
"oc-st",
"fifa17_20801",
94,
"ST",
"Argentina",
"Premier League",
"Chelsea",
),
item(
"oc-cb",
"fifa17_158023",
88,
"CB",
"Argentina",
"Premier League",
"Chelsea",
),
item("oc-mgr", "fifa17_1000509", 0, "", "", "", ""),
item("oc-gk-coach", "fifa17_9000081", 0, "", "", "", ""),
item("oc-fit-coach", "fifa17_3000083", 0, "", "", "", ""),
item("oc-kit-home", "fifa17_6300006", 0, "", "", "", ""),
item("oc-kit-away", "fifa17_6400003", 0, "", "", "", ""),
item("oc-consumable", "fifa17_5003012", 0, "", "", "", ""),
]
}
/// Run one `?type=` query against the mixed club.
fn club_query(query: &str) -> (Vec<Value>, String, &'static str) {
let core = Arc::new(FakeCore::new(mixed_club_items(), 8));
let resolver = resolver_for_catalog(mixed_club_catalog());
let ents = entities();
let hidden = std::collections::HashSet::new();
let kits = CoreKitAssignments {
home_owned_card_id: Some("oc-kit-home".into()),
away_owned_card_id: Some("oc-kit-away".into()),
};
let deps = ClubDeps {
core: core.as_ref(),
entities: &ents,
assets: resolver.as_ref(),
hidden: &hidden,
active_kits: &kits,
};
let (resp, log) = handle_club(query, &deps);
assert_eq!(resp.status, 200, "UTAS must never fail a club query");
let body: Value = serde_json::from_slice(&resp.body).unwrap();
let items = body["itemData"].as_array().cloned().unwrap_or_default();
(items, log.filter, log.outcome)
}
/// Every `?type=` arm that claims a family must serve THAT family and nothing
/// else. The mirror filter is the point: a manager carries nation/leagueId/teamid,
/// so leaking one into a player query would put a coach in the by-league and
/// by-team drill-downs — the exact regression the filter exists to prevent.
#[test]
fn club_type_arms_serve_their_own_family_and_never_leak_another() {
// Players: the untyped fetch, `player`, and the taxonomy's `any`/`custom`
// arms all mean the club's footballers.
for query in ["", "type=player", "type=any", "type=custom"] {
let (items, _, outcome) = club_query(query);
assert_eq!(outcome, "ok", "{query}");
assert_eq!(items.len(), 2, "{query}: two footballers");
for it in &items {
assert_eq!(it["itemType"], "player", "{query}");
assert_eq!(it["cardsubtypeid"], 0, "{query}: no staff/kit subtype");
assert!(it["attributeList"].is_array(), "{query}");
}
}
// The whole staff family, under either observed token.
for query in ["type=staff", "type=manager"] {
let (items, _, outcome) = club_query(query);
assert_eq!(outcome, "ok", "{query}");
let mut subtypes: Vec<i64> = items
.iter()
.map(|i| i["cardsubtypeid"].as_i64().unwrap())
.collect();
subtypes.sort_unstable();
assert_eq!(
subtypes,
vec![4, 6, 8],
"{query}: manager + both coaches, no footballer and no kit"
);
for it in &items {
assert_eq!(it["itemType"], "staff", "{query}");
assert!(it.get("attributeList").is_none(), "{query}");
}
}
// One arm per coach family, by cardsubtypeid.
for (query, subtype) in [
("type=gkcoach", 6),
("type=fitnesscoach", 8),
("type=headcoach", 5),
("type=physio", 7),
] {
let (items, _, outcome) = club_query(query);
assert_eq!(outcome, "ok", "{query}");
let owned: Vec<i64> = items
.iter()
.map(|i| i["cardsubtypeid"].as_i64().unwrap())
.collect();
// The club owns a GK coach and a fitness coach only, so the other two
// arms are legitimately empty — never "everything" to fill the tab.
let expected: Vec<i64> = if [6, 8].contains(&subtype) {
vec![subtype]
} else {
vec![]
};
assert_eq!(owned, expected, "{query}");
assert!(
!owned.contains(&4),
"{query}: the MANAGER is not a coach family"
);
}
// Kits, with their ownership-backed active designations.
let (kits, _, outcome) = club_query("type=kit");
assert_eq!(outcome, "ok");
assert_eq!(kits.len(), 2);
assert_eq!(kits[0]["itemState"], "activeHomeKit");
assert_eq!(kits[1]["itemState"], "activeAwayKit");
for kit in &kits {
assert_eq!(kit["cardsubtypeid"], 9);
assert!(kit.get("attributeList").is_none());
}
// The consumable is in NEITHER: it has its own route and its own envelope.
for query in ["", "type=player", "type=staff", "type=kit"] {
let (items, _, _) = club_query(query);
assert!(
!items
.iter()
.any(|i| i["cardsubtypeid"].as_i64() == Some(54)),
"{query}: a consumable must never appear in a /club item list"
);
}
}
/// The three MY CLUB position tabs answer their own group, from the client's own
/// position ladder — never the whole club.
#[test]
fn position_tabs_serve_only_their_own_position_group() {
let (forwards, _, outcome) = club_query("type=playerforward");
assert_eq!(outcome, "ok");
assert_eq!(forwards.len(), 1);
assert_eq!(forwards[0]["preferredPosition"], "ST");
let (defenders, _, _) = club_query("type=playerdefender");
assert_eq!(defenders.len(), 1);
assert_eq!(defenders[0]["preferredPosition"], "CB");
// No midfielder is owned → an empty tab, not the other five cards.
let (mids, _, outcome) = club_query("type=playermidfielder");
assert_eq!(outcome, "ok");
assert!(mids.is_empty());
// And no position tab ever contains staff or a kit.
for query in [
"type=playerforward",
"type=playerdefender",
"type=playermidfielder",
] {
let (items, _, _) = club_query(query);
for it in &items {
assert_eq!(it["itemType"], "player", "{query}");
}
}
}
/// The arms that are DELIBERATELY empty answer 200 with an empty list and record
/// WHY — distinguishable in the log from a token we do not know.
#[test]
fn withheld_club_type_arms_are_empty_with_a_recorded_reason() {
for (query, reason) in [
("type=equippables", "multi_family_crash_2026_08_05"),
("type=leaguelogos", "subtype_by_elimination_unprobed"),
("type=healing", "served_by_club_consumables_route"),
("type=contract", "served_by_club_consumables_route"),
("type=training", "served_by_club_consumables_route"),
("type=development", "served_by_club_consumables_route"),
("type=unlocks", "not_owned_inventory"),
("type=offlinetrophy", "no_trophy_ownership_in_core"),
("type=onlinetrophy", "no_trophy_ownership_in_core"),
("type=featuredofflinetrophy", "no_trophy_ownership_in_core"),
("type=featuredonlinetrophy", "no_trophy_ownership_in_core"),
("type=allofflinetrophy", "no_trophy_ownership_in_core"),
("type=allonlinetrophy", "no_trophy_ownership_in_core"),
] {
let (items, filter, outcome) = club_query(query);
assert_eq!(outcome, "withheld", "{query}");
assert!(items.is_empty(), "{query} must serve nothing");
assert!(
filter.contains(reason),
"{query}: log must carry the reason, got [{filter}]"
);
}
}
/// The three club-customisation families Core can own are MAPPED (so the arm asks
/// Core for the right rows) but their item record is still withheld, so the answer
/// is an empty list rather than a guessed record — and never another family's.
#[test]
fn club_item_arms_are_mapped_but_withhold_the_unverified_record() {
for query in ["type=badge", "type=stadium", "type=ball", "type=misc"] {
let (items, filter, outcome) = club_query(query);
assert_eq!(outcome, "ok", "{query}");
assert!(
items.is_empty(),
"{query}: the club owns none, and no other family may fill the tab"
);
let token = query.trim_start_matches("type=");
assert!(filter.contains(&format!("type={token}")), "{filter}");
}
}
/// A token outside the client's 30-arm taxonomy stays `unsupported_type`: empty,
/// and loud in the log, never silently mapped onto the player set.
#[test]
fn unknown_club_type_is_unsupported_not_silently_mapped() {
for query in ["type=nonsense", "type=PLAYER", "type=kits"] {
let (items, filter, outcome) = club_query(query);
assert_eq!(outcome, "unsupported_type", "{query}");
assert!(items.is_empty(), "{query}");
assert!(filter.starts_with("type="), "{filter}");
}
}
fn build_server_with_catalog(core: Arc<FakeCore>, upstream: &str, cards_json: &str) -> Server {
let doc = format!("{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{cards_json}}}}}");
let catalog = Fifa17CardCatalog::from_json_str(&doc).unwrap();
let store = JsonIdentityStore::open(unique_store_path()).unwrap();
let resolver = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)));
Server::new(
core,
Arc::new(entities()),
resolver,
Arc::new(PassClient::new(upstream)),
33_068_179,
)
}
/// `GET club/consumables/<category>` is Rust-owned, served from Core, and answers
/// with the STACK wrapper this response class reads — not bare items, which the
/// client accepts and silently discards.
#[test]
fn consumables_route_serves_core_owned_stacks_per_category() {
// Two copies of one training card + one contract card + a footballer.
let items = vec![
item("oc-c1", "fifa17_5003012", 0, "", "", "", ""),
item("oc-c2", "fifa17_5003012", 0, "", "", "", ""),
item("oc-c3", "fifa17_5001004", 0, "", "", "", ""),
item(
"oc-p",
"fifa17_20801",
94,
"ST",
"Argentina",
"Premier League",
"Chelsea",
),
];
let core = Arc::new(FakeCore::new(items, 4));
let (py_url, rec) = spawn_mock_python();
let server = build_server_with_catalog(
core,
&py_url,
"\"fifa17_20801\":{\"asset_id\":20801},\
\"fifa17_5003012\":{\"asset_id\":5003012,\"kind\":\"consumable\",\"subtype\":54,\
\"card_asset_id\":3,\"rareflag\":0,\"rating\":85,\"amount\":15},\
\"fifa17_5001004\":{\"asset_id\":5001004,\"kind\":\"consumable\",\"subtype\":201,\
\"card_asset_id\":7,\"rareflag\":0,\"rating\":60,\"contract\":7}",
);
assert_eq!(
classify("GET", "/ut/game/fifa17/club/consumables/training"),
Route::ClubConsumables,
"a /club PREFIX must not fall through to the generic club route"
);
let resp = server.handle("GET", "/ut/game/fifa17/club/consumables/training", &[], b"");
assert_eq!(resp.status, 200);
let body: Value = serde_json::from_slice(&resp.body).unwrap();
let stacks = body["itemData"].as_array().unwrap();
assert_eq!(stacks.len(), 1, "one stack for the two identical copies");
assert_eq!(stacks[0]["count"], 2);
assert_eq!(stacks[0]["resourceId"], 5_003_012);
assert_eq!(stacks[0]["item"]["cardsubtypeid"], 54);
assert_eq!(
stacks[0]["item"]["cardassetid"], 3,
"the ART id, not the id"
);
assert_eq!(stacks[0]["item"]["amount"], 15, "mandatory for category 0");
assert_eq!(stacks[0]["item"]["rating"], 85, "EA's definition rating");
assert!(
stacks[0]["item"].get("attributeList").is_none(),
"a consumable has no attributes — that is what makes it not a player"
);
// The contracts category serves the OTHER card, and never the training one.
let resp = server.handle(
"GET",
"/ut/game/fifa17/club/consumables/contracts",
&[],
b"",
);
let body: Value = serde_json::from_slice(&resp.body).unwrap();
let stacks = body["itemData"].as_array().unwrap();
assert_eq!(stacks.len(), 1);
assert_eq!(stacks[0]["resourceId"], 5_001_004);
assert_eq!(stacks[0]["item"]["contract"], 7);
assert!(
stacks[0]["item"].get("amount").is_none(),
"categories 2 and 3 ignore `amount`"
);
// A category the club owns nothing in is empty — and the footballer never
// appears in any of them.
for category in ["healing", "fitness", "position", "playstyle"] {
let path = format!("/ut/game/fifa17/club/consumables/{category}");
let resp = server.handle("GET", &path, &[], b"");
let body: Value = serde_json::from_slice(&resp.body).unwrap();
assert!(
body["itemData"].as_array().unwrap().is_empty(),
"{category} must not be filled with another family"
);
}
assert_eq!(rec.lock().len(), 0, "consumables never reach Python");
}
/// An UNKNOWN category segment is empty, not the whole shelf: filling a named tab
/// with every family is the same bug class as answering a drill-down with the
/// entire club.
#[test]
fn consumables_route_unknown_category_serves_nothing() {
let core = Arc::new(FakeCore::new(
vec![item("oc-c1", "fifa17_5003012", 0, "", "", "", "")],
1,
));
let (py_url, rec) = spawn_mock_python();
let server = build_server_with_catalog(
core,
&py_url,
"\"fifa17_5003012\":{\"asset_id\":5003012,\"kind\":\"consumable\",\"subtype\":54,\
\"card_asset_id\":3,\"rareflag\":0,\"rating\":85,\"amount\":15}",
);
for path in [
"/ut/game/fifa17/club/consumables/somethingelse",
"/ut/game/fifa17/club/consumables",
] {
let resp = server.handle("GET", path, &[], b"");
assert_eq!(resp.status, 200, "{path}");
let body: Value = serde_json::from_slice(&resp.body).unwrap();
assert!(body["itemData"].as_array().unwrap().is_empty(), "{path}");
}
assert_eq!(rec.lock().len(), 0, "still never Python");
}
/// A consumable whose catalog entry is INCOMPLETE is dropped, not drawn wrong:
/// without `amount` the client renders "-1" (its parser initialises the temp to
/// -1 and sign-extends), and without a real `cardassetid` it draws the
/// `notfound.swf` green box.
#[test]
fn incomplete_consumable_definitions_are_dropped_not_drawn_wrong() {
let core = Arc::new(FakeCore::new(
vec![
item("oc-no-amount", "fifa17_5003011", 0, "", "", "", ""),
item("oc-no-art", "fifa17_5003013", 0, "", "", "", ""),
],
2,
));
let (py_url, _rec) = spawn_mock_python();
let server = build_server_with_catalog(
core,
&py_url,
// (a) art id present, `amount` missing; (b) `amount` present, art missing.
"\"fifa17_5003011\":{\"asset_id\":5003011,\"kind\":\"consumable\",\"subtype\":54,\
\"card_asset_id\":3,\"rareflag\":0,\"rating\":65},\
\"fifa17_5003013\":{\"asset_id\":5003013,\"kind\":\"consumable\",\"subtype\":54,\
\"rareflag\":0,\"rating\":85,\"amount\":15}",
);
let resp = server.handle("GET", "/ut/game/fifa17/club/consumables/training", &[], b"");
assert_eq!(resp.status, 200);
let body: Value = serde_json::from_slice(&resp.body).unwrap();
assert!(
body["itemData"].as_array().unwrap().is_empty(),
"neither definition can be drawn honestly"
);
}