9026220533
ROOT CAUSE, one line. openfut-import-fifa17 emitted `"overall": 0` for every non-player Core definition while `d.rating` already held EA's authoritative `value` -- and the very next block wrote that same number correctly to the adapter catalog. So the tier existed host-side but never reached Core: Core overall 0 -> /collection effective_overall 0 -> CoreOwnedItem.rating 0 -> tier_for_rating(0) = Bronze for a Gold (88) manager. That silent mis-grant is exactly what the 409 was protecting against, so the refusal was correct. The emitter now also writes `source_rating`, keeping `overall` at 0. Regenerating the production pack changes exactly 18 entries and exactly one field each (source_rating None -> value); same 1710 ids, same fingerprint 28c333f1e833338a. WHY value IS the tier source, and why the thresholds are the player ladder: LIVE_PROVEN, not inferred. The client re-rates staff from its own managercards/*coachcards/physiocards by carddbid and applies discard_level's 65/75 ladder; coach_probe/discard_probe agree 4/4 (manager value 88 -> level 3, coaches 66 -> level 2). The shipped coach tables corroborate: each family has exactly 3 tiers x 2 rarities, and only 65/75 splits them 2/2/2. Manager contracts stop refusing and now resolve the TARGET's tier from Core-owned state. Still fail-closed everywhere it matters: a coach or physio is `contract_target_not_a_manager` (only cardsubtypeid 4 is a manager), and a manager Core carries no source_rating for is `manager_tier_unknown` rather than a guessed tier. Core's own content_kind token is sent as target_kind, because Core calls the squad manager `manager` while the catalog classifies it `staff`+subtype 4. NOT implemented, unchanged: STORED_MANAGER_BONUS and MATCH_CONTRACT_DECREMENT.
642 lines
24 KiB
Rust
642 lines
24 KiB
Rust
//! Shape OpenFUT Core's semantic owned inventory into the FIFA 17 `/club`
|
|
//! response envelope `{"itemData":[ <player item>, … ]}`.
|
|
//!
|
|
//! This module owns only the **`/club` envelope**; the per-item shape lives in
|
|
//! the shared [`crate::fut::item`] primitive so `/club` and squad projection
|
|
//! emit byte-identical items. Items whose real FIFA asset id is unknown are
|
|
//! **dropped and counted** here (a collection may omit an unrenderable card);
|
|
//! squad projection, which cannot omit a starter, refuses instead.
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
use crate::fut::content_taxonomy::ContentKind;
|
|
use crate::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
|
|
use crate::fut::entities::ReverseEntityResolver;
|
|
use crate::fut::item::{shape_club_item, shape_item, shape_staff_item, STAFF_CONTRACT};
|
|
use crate::fut::item_state;
|
|
// Re-exported so existing `club_response::{…}` callers keep working; the types
|
|
// are now defined once in `fut::item`.
|
|
pub use crate::fut::item::{
|
|
CoreOwnedItem, Fifa17ConsumableIdentity, Fifa17Identity, Fifa17KitIdentity,
|
|
Fifa17StaffIdentity, ItemIdentityResolver, ShapeStats,
|
|
};
|
|
|
|
/// Active club-level kit roles, keyed by Core owned-instance id.
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
pub struct ActiveKitAssignments<'a> {
|
|
pub home: Option<&'a str>,
|
|
pub away: Option<&'a str>,
|
|
}
|
|
|
|
/// Shape the player portion of `/club` (the historical/default query).
|
|
pub fn shape_club_response<I: ItemIdentityResolver + ?Sized>(
|
|
items: &[CoreOwnedItem],
|
|
ent: &impl ReverseEntityResolver,
|
|
ident: &I,
|
|
) -> (Value, ShapeStats) {
|
|
shape_club_response_with_kits(items, ent, ident, ActiveKitAssignments::default())
|
|
}
|
|
|
|
/// Shape `/club` items, including ownership-backed active kit designations.
|
|
///
|
|
/// This envelope carries the two families whose record shape it can carry:
|
|
/// players and kits, plus the staff family (manager + the four coach families).
|
|
/// Consumables have their own route and their own STACK envelope, and the
|
|
/// club-customisation families are counted and withheld — see each arm.
|
|
pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
|
|
items: &[CoreOwnedItem],
|
|
ent: &impl ReverseEntityResolver,
|
|
ident: &I,
|
|
active_kits: ActiveKitAssignments<'_>,
|
|
) -> (Value, ShapeStats) {
|
|
let mut out = Vec::with_capacity(items.len());
|
|
let mut stats = ShapeStats::default();
|
|
for item in items {
|
|
match ident.kind_of(item) {
|
|
ContentKind::Player => match ident.resolve(item) {
|
|
Some(id) => {
|
|
out.push(shape_item(
|
|
item,
|
|
id,
|
|
ent,
|
|
ident.discard_value(item),
|
|
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
|
|
));
|
|
stats.emitted += 1;
|
|
}
|
|
None => stats.dropped_no_asset += 1,
|
|
},
|
|
// Kit, badge and stadium are ONE cardtype-7 record with one
|
|
// client-side resolver; only the equipped designation differs.
|
|
ContentKind::Kit | ContentKind::Badge | ContentKind::Stadium => {
|
|
match ident.resolve_kit(item) {
|
|
Some(id) => {
|
|
let state = if active_kits.home == Some(item.owned_card_id.as_str()) {
|
|
item_state::ACTIVE_HOME_KIT
|
|
} else if active_kits.away == Some(item.owned_card_id.as_str()) {
|
|
item_state::ACTIVE_AWAY_KIT
|
|
} else {
|
|
item_state::FREE
|
|
};
|
|
out.push(shape_club_item(id, state));
|
|
stats.emitted += 1;
|
|
}
|
|
None => stats.dropped_no_asset += 1,
|
|
}
|
|
}
|
|
// A manager is a staff card: both Core kinds resolve through the one
|
|
// staff record shape, discriminated on the wire by `cardsubtypeid`
|
|
// (the same set as `ContentKind::is_staff_family`, spelled out here
|
|
// because a guard arm would not prove exhaustiveness).
|
|
ContentKind::Manager | ContentKind::Staff => match ident.resolve_staff(item) {
|
|
Some(id) => {
|
|
out.push(shape_staff_item(
|
|
id,
|
|
item.contract_matches.unwrap_or(STAFF_CONTRACT),
|
|
));
|
|
stats.emitted += 1;
|
|
}
|
|
None => stats.dropped_no_asset += 1,
|
|
},
|
|
// Consumables have their OWN route and their own envelope:
|
|
// `GET club/consumables/<category>`, whose element is a stack
|
|
// wrapper, not an item (see [`crate::fut::consumables`]). A bare
|
|
// consumable item in THIS envelope is accepted by the client and
|
|
// silently discarded, so emitting one here would be a 200 that does
|
|
// nothing — the worst failure shape in this project. Counted.
|
|
ContentKind::Consumable => {
|
|
stats.excluded_non_player += 1;
|
|
}
|
|
// The cardtype-9 families. Unlike kits/badges/stadia these have NO
|
|
// database name resolver at all, so the displayed name can only come
|
|
// from `localizedName` on the wire. That offset is confirmed
|
|
// (`+0xd9`), but "the parser reads it" is NOT "sending it is safe",
|
|
// and this project pays for that distinction with a client freeze.
|
|
// Counted and withheld rather than guessed: ownership stays
|
|
// authoritative in Core either way, and club/stats still counts the
|
|
// families so the screen's own numbers are right.
|
|
ContentKind::Ball | ContentKind::Misc => {
|
|
stats.excluded_non_player += 1;
|
|
}
|
|
}
|
|
}
|
|
(json!({ "itemData": out }), stats)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::fut::contract_cards::CONTRACT_MATCH_CAP;
|
|
use crate::fut::entities::Fifa17Entities;
|
|
use std::collections::HashMap;
|
|
|
|
fn entities() -> Fifa17Entities {
|
|
Fifa17Entities::from_maps(
|
|
HashMap::from([(13, "Premier League".to_string())]),
|
|
HashMap::from([(52, "Argentina".to_string())]),
|
|
HashMap::from([(5, "Chelsea".to_string())]),
|
|
)
|
|
}
|
|
|
|
fn item(
|
|
owned: &str,
|
|
card: &str,
|
|
rating: u8,
|
|
pos: &str,
|
|
nation: &str,
|
|
league: &str,
|
|
club: &str,
|
|
) -> CoreOwnedItem {
|
|
CoreOwnedItem {
|
|
owned_card_id: owned.into(),
|
|
card_id: card.into(),
|
|
rating,
|
|
position: pos.into(),
|
|
nation: nation.into(),
|
|
league: league.into(),
|
|
club: club.into(),
|
|
attributes: [90, 88, 70, 85, 40, 78],
|
|
// Untracked by default, so these fixtures exercise the pack-fresh
|
|
// fallback; a test that cares sets it explicitly.
|
|
contract_matches: None,
|
|
source_rating: None,
|
|
core_content_kind: None,
|
|
}
|
|
}
|
|
|
|
/// Test resolver: card_id -> real asset id, item_id from a table. Stands in
|
|
/// for the (unresolved-in-production) Core-card→asset mapping.
|
|
struct MapIdentity(HashMap<String, Fifa17Identity>);
|
|
impl ItemIdentityResolver for MapIdentity {
|
|
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
|
self.0.get(&it.card_id).copied()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn shapes_item_with_full_field_set_and_reverse_ids() {
|
|
let ent = entities();
|
|
let ident = MapIdentity(HashMap::from([(
|
|
"card_ch_1".to_string(),
|
|
Fifa17Identity {
|
|
item_id: 100000001,
|
|
asset_id: 20801,
|
|
resource_id: 20801,
|
|
rareflag: 1,
|
|
},
|
|
)]));
|
|
let items = vec![item(
|
|
"oc1",
|
|
"card_ch_1",
|
|
86,
|
|
"CDM",
|
|
"Argentina",
|
|
"Premier League",
|
|
"Chelsea",
|
|
)];
|
|
let (body, stats) = shape_club_response(&items, &ent, &ident);
|
|
assert_eq!(stats.emitted, 1);
|
|
assert_eq!(stats.dropped_no_asset, 0);
|
|
let it = &body["itemData"][0];
|
|
assert_eq!(it["id"], 100000001);
|
|
assert_eq!(it["resourceId"], 20801);
|
|
assert_eq!(it["assetId"], 20801);
|
|
assert_eq!(
|
|
it["definitionId"], 20801,
|
|
"version byte 0 => resourceId==assetId==definitionId"
|
|
);
|
|
assert_eq!(it["rating"], 86);
|
|
assert_eq!(it["preferredPosition"], "CDM");
|
|
assert_eq!(it["leagueId"], 13);
|
|
assert_eq!(it["teamid"], 5);
|
|
assert_eq!(it["nation"], 52);
|
|
assert_eq!(it["itemType"], "player");
|
|
assert_eq!(it["rareflag"], 1);
|
|
assert_eq!(it["contract"], 7);
|
|
assert_eq!(it["fitness"], 99);
|
|
assert_eq!(it["attributeList"].as_array().unwrap().len(), 6);
|
|
assert_eq!(it["attributeList"][0], json!({"index":0,"value":90}));
|
|
}
|
|
|
|
#[test]
|
|
fn drops_items_without_a_real_asset_id_never_faking() {
|
|
let ent = entities();
|
|
// Empty identity map == the current synthetic-catalogue reality.
|
|
let ident = MapIdentity(HashMap::new());
|
|
let items = vec![item(
|
|
"oc1",
|
|
"card_pl_001",
|
|
84,
|
|
"ST",
|
|
"England",
|
|
"Premier League",
|
|
"Northgate United",
|
|
)];
|
|
let (body, stats) = shape_club_response(&items, &ent, &ident);
|
|
assert_eq!(stats.emitted, 0);
|
|
assert_eq!(stats.dropped_no_asset, 1);
|
|
assert_eq!(
|
|
body["itemData"].as_array().unwrap().len(),
|
|
0,
|
|
"no fabricated ids emitted"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unresolved_entity_names_become_neutral_zero_not_dropped() {
|
|
let ent = entities();
|
|
let ident = MapIdentity(HashMap::from([(
|
|
"card_x".to_string(),
|
|
Fifa17Identity {
|
|
item_id: 100000002,
|
|
asset_id: 158023,
|
|
resource_id: 158023,
|
|
rareflag: 1,
|
|
},
|
|
)]));
|
|
// Synthetic club "Northgate United" has no FIFA team id.
|
|
let items = vec![item(
|
|
"oc2",
|
|
"card_x",
|
|
84,
|
|
"ST",
|
|
"England",
|
|
"Premier League",
|
|
"Northgate United",
|
|
)];
|
|
let (body, _) = shape_club_response(&items, &ent, &ident);
|
|
let it = &body["itemData"][0];
|
|
assert_eq!(
|
|
it["teamid"], 0,
|
|
"unknown club -> neutral 0, item still emitted"
|
|
);
|
|
assert_eq!(it["leagueId"], 13);
|
|
assert_eq!(it["nation"], 0, "England not in the test nation map -> 0");
|
|
}
|
|
|
|
#[test]
|
|
fn envelope_is_itemdata_object() {
|
|
let ent = entities();
|
|
let ident = MapIdentity(HashMap::new());
|
|
let (body, _) = shape_club_response(&[], &ent, &ident);
|
|
assert!(body.get("itemData").unwrap().is_array());
|
|
assert_eq!(
|
|
body.as_object().unwrap().len(),
|
|
1,
|
|
"only itemData at top level"
|
|
);
|
|
}
|
|
|
|
/// A resolver that resolves an asset id for EVERY item (so exclusion is not
|
|
/// an artifact of a missing asset) but classifies some card_ids as non-player
|
|
/// via an explicit kind table.
|
|
struct KindMapIdentity {
|
|
ids: HashMap<String, Fifa17Identity>,
|
|
kinds: HashMap<String, ContentKind>,
|
|
kits: HashMap<String, Fifa17KitIdentity>,
|
|
staff: HashMap<String, Fifa17StaffIdentity>,
|
|
}
|
|
impl ItemIdentityResolver for KindMapIdentity {
|
|
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
|
self.ids.get(&it.card_id).copied()
|
|
}
|
|
fn resolve_kit(&self, it: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
|
|
self.kits.get(&it.card_id).copied()
|
|
}
|
|
fn resolve_staff(&self, it: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
|
|
self.staff.get(&it.card_id).copied()
|
|
}
|
|
fn kind_of(&self, it: &CoreOwnedItem) -> ContentKind {
|
|
self.kinds
|
|
.get(&it.card_id)
|
|
.copied()
|
|
.unwrap_or(ContentKind::Player)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn consumables_are_excluded_but_staff_is_shaped() {
|
|
let ent = entities();
|
|
let id = |item_id: u32, asset: u32| Fifa17Identity {
|
|
item_id,
|
|
asset_id: asset,
|
|
resource_id: asset,
|
|
rareflag: 1,
|
|
};
|
|
let ident = KindMapIdentity {
|
|
ids: HashMap::from([
|
|
("card_player".to_string(), id(100000001, 20801)),
|
|
("card_consumable".to_string(), id(100000002, 5003012)),
|
|
]),
|
|
kits: HashMap::new(),
|
|
staff: HashMap::from([(
|
|
"card_staff".to_string(),
|
|
Fifa17StaffIdentity {
|
|
item_id: 100000003,
|
|
resource_id: 3000083,
|
|
subtype: 8,
|
|
nation: 0,
|
|
league_id: 0,
|
|
team_id: 0,
|
|
},
|
|
)]),
|
|
kinds: HashMap::from([
|
|
("card_consumable".to_string(), ContentKind::Consumable),
|
|
("card_staff".to_string(), ContentKind::Staff),
|
|
]),
|
|
};
|
|
let items = vec![
|
|
item(
|
|
"oc1",
|
|
"card_player",
|
|
86,
|
|
"ST",
|
|
"Argentina",
|
|
"Premier League",
|
|
"Chelsea",
|
|
),
|
|
item("oc2", "card_consumable", 0, "", "", "", ""),
|
|
item("oc3", "card_staff", 0, "", "", "", ""),
|
|
];
|
|
let (body, stats) = shape_club_response(&items, &ent, &ident);
|
|
assert_eq!(
|
|
stats.emitted, 2,
|
|
"the player and the staff card are emitted"
|
|
);
|
|
assert_eq!(
|
|
stats.excluded_non_player, 1,
|
|
"only the consumable is excluded; staff has a wire envelope of its own"
|
|
);
|
|
assert_eq!(stats.dropped_no_asset, 0);
|
|
let arr = body["itemData"].as_array().unwrap();
|
|
assert_eq!(arr.len(), 2);
|
|
assert_eq!(arr[0]["id"], 100000001, "the player survives");
|
|
assert_eq!(arr[0]["itemType"], "player");
|
|
let coach = &arr[1];
|
|
assert_eq!(coach["id"], 100000003);
|
|
assert_eq!(coach["resourceId"], 3000083);
|
|
assert_eq!(coach["cardsubtypeid"], 8);
|
|
assert_eq!(coach["itemType"], "staff");
|
|
assert_eq!(
|
|
coach["contract"], STAFF_CONTRACT,
|
|
"this fixture is UNTRACKED (contract_matches None), so the wire shows \
|
|
the pack-fresh fallback — not because the shaper hardcodes it"
|
|
);
|
|
assert!(
|
|
coach.get("nation").is_none()
|
|
&& coach.get("leagueId").is_none()
|
|
&& coach.get("teamid").is_none(),
|
|
"a COACH has no nation/league/team column in the client's tables, so \
|
|
those keys must be absent rather than invented as zeroes"
|
|
);
|
|
assert!(
|
|
coach.get("attributeList").is_none() && coach.get("preferredPosition").is_none(),
|
|
"both survive the client's merge and are read by the card view-model"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn manager_carries_the_chemistry_fields_only_the_server_can_supply() {
|
|
let ent = entities();
|
|
let ident = KindMapIdentity {
|
|
ids: HashMap::new(),
|
|
kits: HashMap::new(),
|
|
staff: HashMap::from([(
|
|
"card_manager".to_string(),
|
|
Fifa17StaffIdentity {
|
|
item_id: 100004871,
|
|
resource_id: 1000509,
|
|
subtype: 4,
|
|
nation: 45,
|
|
league_id: 53,
|
|
team_id: 241,
|
|
},
|
|
)]),
|
|
kinds: HashMap::from([("card_manager".to_string(), ContentKind::Staff)]),
|
|
};
|
|
let items = vec![item("oc-mgr", "card_manager", 0, "", "", "", "")];
|
|
let (body, stats) = shape_club_response(&items, &ent, &ident);
|
|
assert_eq!(stats.emitted, 1);
|
|
let mgr = &body["itemData"][0];
|
|
assert_eq!(
|
|
mgr["cardsubtypeid"], 4,
|
|
"subtype alone selects managercards"
|
|
);
|
|
assert_eq!(
|
|
mgr["resourceId"], 1000509,
|
|
"the merge key is read RAW: it must equal the carddbid with no version byte"
|
|
);
|
|
// rec+0xde / rec+0xe0 / rec+0x94 — the merge never writes these, so an
|
|
// omission here is an unrecoverable blank flag and zero chemistry.
|
|
assert_eq!(mgr["nation"], 45);
|
|
assert_eq!(mgr["leagueId"], 53);
|
|
assert_eq!(mgr["teamid"], 241);
|
|
assert_eq!(
|
|
mgr["contract"], STAFF_CONTRACT,
|
|
"untracked fixture => pack-fresh fallback"
|
|
);
|
|
assert_eq!(mgr["itemState"], "free");
|
|
assert_eq!(mgr["owners"], 1);
|
|
let keys: Vec<&String> = mgr.as_object().unwrap().keys().collect();
|
|
assert_eq!(
|
|
keys.len(),
|
|
11,
|
|
"exactly the 11 justified keys, no more: {keys:?}"
|
|
);
|
|
}
|
|
|
|
/// `/club` is the screen a contract apply is judged on: if the envelope keeps
|
|
/// reporting the pack-fresh count, a committed apply is invisible and the
|
|
/// operator sees a 200 that did nothing. Both families must carry the number
|
|
/// Core persisted.
|
|
#[test]
|
|
fn club_reports_the_contract_core_persisted_for_players_and_staff() {
|
|
let ent = entities();
|
|
let ident = KindMapIdentity {
|
|
ids: HashMap::from([(
|
|
"card_player".to_string(),
|
|
Fifa17Identity {
|
|
item_id: 100000001,
|
|
asset_id: 20801,
|
|
resource_id: 20801,
|
|
rareflag: 1,
|
|
},
|
|
)]),
|
|
kits: HashMap::new(),
|
|
staff: HashMap::from([(
|
|
"card_manager".to_string(),
|
|
Fifa17StaffIdentity {
|
|
item_id: 100004871,
|
|
resource_id: 1000509,
|
|
subtype: 4,
|
|
nation: 45,
|
|
league_id: 53,
|
|
team_id: 241,
|
|
},
|
|
)]),
|
|
kinds: HashMap::from([
|
|
("card_player".to_string(), ContentKind::Player),
|
|
("card_manager".to_string(), ContentKind::Staff),
|
|
]),
|
|
};
|
|
// A player mid-way through its contracts, a fully topped-up manager, and
|
|
// one untracked player that must fall back.
|
|
let mut played = item(
|
|
"oc-played",
|
|
"card_player",
|
|
86,
|
|
"ST",
|
|
"Argentina",
|
|
"Premier League",
|
|
"Chelsea",
|
|
);
|
|
played.contract_matches = Some(3);
|
|
let mut manager = item("oc-mgr", "card_manager", 0, "", "", "", "");
|
|
manager.contract_matches = Some(CONTRACT_MATCH_CAP);
|
|
let untracked = item(
|
|
"oc-fresh",
|
|
"card_player",
|
|
86,
|
|
"ST",
|
|
"Argentina",
|
|
"Premier League",
|
|
"Chelsea",
|
|
);
|
|
|
|
let (body, stats) = shape_club_response(&[played, manager, untracked], &ent, &ident);
|
|
assert_eq!(stats.emitted, 3);
|
|
let arr = body["itemData"].as_array().unwrap();
|
|
assert_eq!(arr[0]["contract"], 3, "the player's persisted count");
|
|
assert_eq!(
|
|
arr[1]["contract"], CONTRACT_MATCH_CAP,
|
|
"staff read the same persisted field, not STAFF_CONTRACT"
|
|
);
|
|
assert_eq!(
|
|
arr[2]["contract"], PACK_FRESH_CONTRACT_MATCHES,
|
|
"only an untracked instance falls back"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn kits_project_with_owned_active_home_and_away_states() {
|
|
let ent = entities();
|
|
let kit = |item_id, resource_id, team_id| Fifa17KitIdentity {
|
|
item_id,
|
|
asset_id: resource_id,
|
|
resource_id,
|
|
card_asset_id: 35,
|
|
subtype: 9,
|
|
team_id,
|
|
};
|
|
let ident = KindMapIdentity {
|
|
ids: HashMap::new(),
|
|
staff: HashMap::new(),
|
|
kits: HashMap::from([
|
|
("kit-home".into(), kit(100000010, 6300006, 21)),
|
|
("kit-away".into(), kit(100000011, 6400003, 21)),
|
|
]),
|
|
kinds: HashMap::from([
|
|
("kit-home".into(), ContentKind::Kit),
|
|
("kit-away".into(), ContentKind::Kit),
|
|
]),
|
|
};
|
|
let items = vec![
|
|
item("owned-home", "kit-home", 0, "", "", "", ""),
|
|
item("owned-away", "kit-away", 0, "", "", "", ""),
|
|
];
|
|
let (body, stats) = shape_club_response_with_kits(
|
|
&items,
|
|
&ent,
|
|
&ident,
|
|
ActiveKitAssignments {
|
|
home: Some("owned-home"),
|
|
away: Some("owned-away"),
|
|
},
|
|
);
|
|
assert_eq!(stats.emitted, 2);
|
|
assert_eq!(body["itemData"][0]["resourceId"], 6300006);
|
|
assert_eq!(body["itemData"][0]["cardassetid"], 35);
|
|
assert_eq!(body["itemData"][0]["cardsubtypeid"], 9);
|
|
assert_eq!(body["itemData"][0]["teamid"], 21);
|
|
assert_eq!(body["itemData"][0]["itemState"], "activeHomeKit");
|
|
assert_eq!(body["itemData"][1]["itemState"], "activeAwayKit");
|
|
assert!(body["itemData"][0].get("attributeList").is_none());
|
|
assert!(body["itemData"][0].get("itemType").is_none());
|
|
}
|
|
|
|
/// Kit, badge and stadium are one cardtype-7 record and MUST all project.
|
|
/// Ball and league logo are cardtype 9, have no database name resolver, and
|
|
/// stay withheld until `localizedName` is established as safe to send.
|
|
/// Counting a family in club/stats while never shaping it is the divergence
|
|
/// this test pins: the wire set and the withheld set are both asserted.
|
|
#[test]
|
|
fn cardtype7_club_items_project_and_cardtype9_stay_withheld() {
|
|
let ent = entities();
|
|
let kit_id = |item_id, resource, subtype, art| Fifa17KitIdentity {
|
|
item_id,
|
|
asset_id: resource,
|
|
resource_id: resource,
|
|
card_asset_id: art,
|
|
subtype,
|
|
team_id: 21,
|
|
};
|
|
let ident = KindMapIdentity {
|
|
ids: HashMap::new(),
|
|
kinds: HashMap::from([
|
|
("c_kit".to_string(), ContentKind::Kit),
|
|
("c_badge".to_string(), ContentKind::Badge),
|
|
("c_stadium".to_string(), ContentKind::Stadium),
|
|
("c_ball".to_string(), ContentKind::Ball),
|
|
("c_logo".to_string(), ContentKind::Misc),
|
|
]),
|
|
kits: HashMap::from([
|
|
("c_kit".to_string(), kit_id(1, 6_300_006, 9, 35)),
|
|
("c_badge".to_string(), kit_id(2, 6_000_005, 11, 39)),
|
|
("c_stadium".to_string(), kit_id(3, 6_200_000, 10, 36)),
|
|
// Resolvable on purpose: withholding must be a decision about the
|
|
// FAMILY, not an accident of a missing identity.
|
|
("c_ball".to_string(), kit_id(4, 8_120_194, 30, 37)),
|
|
("c_logo".to_string(), kit_id(5, 8_010_015, 31, 40)),
|
|
]),
|
|
staff: HashMap::new(),
|
|
};
|
|
let items: Vec<CoreOwnedItem> = ["c_kit", "c_badge", "c_stadium", "c_ball", "c_logo"]
|
|
.iter()
|
|
.map(|c| item(&format!("oc_{c}"), c, 0, "", "", "", ""))
|
|
.collect();
|
|
|
|
let (body, stats) = shape_club_response_with_kits(
|
|
&items,
|
|
&ent,
|
|
&ident,
|
|
ActiveKitAssignments {
|
|
home: None,
|
|
away: None,
|
|
},
|
|
);
|
|
let arr = body["itemData"].as_array().unwrap();
|
|
assert_eq!(stats.emitted, 3, "kit + badge + stadium");
|
|
assert_eq!(stats.excluded_non_player, 2, "ball + league logo withheld");
|
|
assert_eq!(stats.dropped_no_asset, 0, "withholding is not a drop");
|
|
|
|
let subtypes: Vec<i64> = arr
|
|
.iter()
|
|
.map(|i| i["cardsubtypeid"].as_i64().unwrap())
|
|
.collect();
|
|
assert_eq!(subtypes, vec![9, 11, 10]);
|
|
// teamid only where the caption resolves TeamName_Abbr15_<teamid>.
|
|
assert_eq!(arr[0]["teamid"], 21, "kit");
|
|
assert_eq!(arr[1]["teamid"], 21, "badge");
|
|
assert!(
|
|
arr[2].get("teamid").is_none(),
|
|
"stadium caption reads assetId"
|
|
);
|
|
for it in arr {
|
|
assert!(
|
|
item_state::is_recovered(it["itemState"].as_str().unwrap()),
|
|
"every emitted state must be a recovered token"
|
|
);
|
|
}
|
|
}
|
|
}
|