6d89d62e41
Measured live 2026-08-23 against the client parked on the pre-match kit selector
(pid 8793, read-only /proc/PID/mem). Whether a served club item becomes a
resident item record correlates perfectly with whether we send itemType:
family itemType sent resident record?
player "player" yes
staff "staff" yes
kit (absent) NO
badge (absent) NO
stadium (absent) NO
Two of two families that carry it are ingested; none of the three that omit it
is. With no cardtype-7 record resident the club scan FUN_1800d73d0 matches
nothing, the FUT match-kit DataProvider is built empty (traced: KITS_AVAILABLE
= 0), and the selector falls back to catalogue-gated engine kits - which is the
"This kit is currently locked. To unlock and use it, please go to the Football
Club Catalogue." dialog the operator sees. That string lives in the Flash UI, and
Blaze receives no catalogue or unlock request at any point, so the lock is
decided client-side from data the client already holds.
CARD_SYSTEM.md records that itemType "is parsed into a heap string and never
stored". That stays true of the RECORD; it does not follow that the string is
unused, and the correlation is evidence it is consulted before the record is
kept.
Tokens come from the ?type= vocabulary decoded from the FUN_18012ec50 jump table
(kit 12, stadium 13, badge 11) - the same vocabulary the two working families
use. INFERRED, not proven: no real EA club item exists anywhere in the capture
corpus, so the exact token is taken from the atom vocabulary, not observed wire.
Near miss worth recording: STADIUM_SUBTYPE was not imported at module scope, so
match treated it as a fresh binding instead of a constant, made that arm
irrefutable and typed badges as "stadium". It compiled with only an
unused-variable warning. every_cardtype7_family_carries_its_own_item_type
asserts three distinct tokens, which is what catches that.
cargo test --workspace: 1240 passed, 0 failed.
646 lines
24 KiB
Rust
646 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,
|
|
category: 2,
|
|
year: 0,
|
|
};
|
|
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_eq!(body["itemData"][0]["itemType"], "kit");
|
|
}
|
|
|
|
/// 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,
|
|
category: 2,
|
|
year: 0,
|
|
};
|
|
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"
|
|
);
|
|
}
|
|
}
|
|
}
|