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:
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user