49b18dd4ac
Quick-sell paid an invented five-tier rating ladder (its own comment said "PLACEHOLDER, not EA-authentic"). It was blind to card type and rareflag, so a 94-rated TOTW special and a 94-rated gold common both sold for 1500, and every non-player -- whose Core overall is 0 -- sold for the flat 150 floor. The ladder existed in three places (adapter wire, host payout, an integration test's private copy), which is a drift waiting to happen. Add openfut-adapter-fifa17::fut::discard: the client's own fcc_discardcoins table and its formula, round_half_up(rating * price / 100), keyed (cardtype, level, rare). All of it is already reversed in plan-2026-08-05-store-subsystem.md 3.6 and was verified there against 22 live club items, 22 of 22 exact. DISCARD_COINS is generated from fifa17-recon/data/tables/fcc_discardcoins.json and a test re-reads that file and asserts row-for-row agreement, so the transcription cannot drift. Collapse the three ladders into one method. ItemIdentityResolver::discard_value both stamps the wire discardValue and prices the sale, because a non-zero discardValue suppresses the client's local computation -- whatever is sent is what the player is promised. The host's quick_sell_value is deleted and the integration test's copy now calls the single implementation. A test with a resolver double returning an impossible price proves the credit follows the wire; reverting the payout to a ladder fails it. Gated on OPENFUT_FIFA17_DISCARD_TABLE=1, default off: switching revalues the real 1991-item club 10.5x (1,820,400 -> 19,128,955 coins if wholly liquidated), up for specials and DOWN for consumables, which the ladder overpaid 5.5x. That is an operator's decision. Staff decline to the ladder rather than pay 0: the client re-rates cardtypes 2/3/4/5/10 from its own DB and their rating is not imported. Deliberately not guessed -- see the falsifier in the doc. Verified on staging with the real club, both modes: flag off 1500 wire / 1500 paid; flag on 23760 wire / 23760 paid on an r99 rareflag-11 card (99*24000/100). Consumables price from their catalog rating and agree with the client's own computation. Adapter 244 lib tests, host 121 lib + 45 host_test, fmt and clippy clean.
547 lines
21 KiB
Rust
547 lines
21 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::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)));
|
|
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, 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::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],
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
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);
|
|
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:?}"
|
|
);
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
}
|
|
}
|
|
}
|