//! The shared FIFA 17 FUT **item-shaping primitive**. //! //! One function shapes one owned FUT item into the numeric card object the FIFA //! 17 client renders, and **every** route that emits a player item goes through //! it — `/club` (via [`crate::fut::club_response`]) and squad projection (via //! [`crate::fut::squad_projection`]) alike. There is deliberately no second copy //! of the field set: an item is shaped in exactly one place so the two routes //! can never drift. //! //! ## The asset-id boundary (load-bearing, evidence-grounded) //! //! FIFA renders a card by resolving `resourceId & 0xffffff` against the client's //! OWN local players table (proven live): a real id renders a real footballer, //! an **invented id renders a blank generic card**. OpenFUT Core's catalogue is //! synthetic string ids (`card_pl_001`) with no FIFA asset id. So an //! [`ItemIdentityResolver`] is injected; when it cannot supply a **real** FIFA //! asset id for an item, the item carries no fabricated identity — the caller //! decides what that means (`/club` drops and counts it; a squad refuses to //! project a starter it cannot render, never faking one). //! //! Entity ids (`leagueId`/`teamid`/`nation`) come from a reverse resolver; an //! unresolved name yields a neutral `0` (a valid int — non-fatal; it only means //! "no badge/flag"), because those are not the identity the renderer keys on. use serde_json::{json, Value}; use crate::fut::content_taxonomy::ContentKind; use crate::fut::entities::ReverseEntityResolver; /// One owned item in game-independent terms, as read from Core's inventory. #[derive(Debug, Clone)] pub struct CoreOwnedItem { /// Core owned-instance id (string). The stable per-copy identity — two /// copies of the same card definition have distinct `owned_card_id`s. pub owned_card_id: String, /// Core card-definition id (string), used for asset resolution. pub card_id: String, /// Effective overall rating. pub rating: u8, /// Effective position, e.g. "ST". pub position: String, pub nation: String, pub league: String, pub club: String, /// [pace, shooting, passing, dribbling, defending, physical]. pub attributes: [u8; 6], } /// The FIFA-side numeric identity of an owned item. `asset_id` MUST be a real /// FIFA player asset (low 24 bits the client resolves); `item_id` is the wire /// instance id used for later item operations. Two owned copies of the same /// definition share an `asset_id` but MUST have distinct `item_id`s. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Fifa17Identity { pub item_id: u32, /// Base FIFA player asset (low 24 bits the client resolves art/name from). pub asset_id: u32, /// Full versioned resource id = `(version << 24) | asset_id`. Equals /// `asset_id` for a version-0 base card. This is the wire /// `resourceId`/`definitionId`, kept DISTINCT from `asset_id` so a versioned /// (special) card never collapses onto its base on the wire. pub resource_id: u32, /// FIFA wire `rareflag` — the card's rare/special TYPE (e.g. 3=inform, /// 21..=24 = special programmes). Drives the client's special-card art; /// carried from the catalog, never hardcoded, so specials render as specials. pub rareflag: i64, } /// Supplies the FIFA numeric identity for a Core item. Returning `None` means /// "no real FIFA asset id known" → the caller must not fabricate one. pub trait ItemIdentityResolver { fn resolve(&self, item: &CoreOwnedItem) -> Option; /// Classify a Core item's definition as player/consumable/staff. Defaults to /// [`ContentKind::Player`] so existing resolvers keep their behaviour; a /// catalog-backed resolver overrides this to consult its `kind_of`, letting /// `/club` exclude non-player content (which must never render as a /// 0-rated player). fn kind_of(&self, _item: &CoreOwnedItem) -> ContentKind { ContentKind::Player } } /// Diagnostics from shaping (safe to log — counts only). #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct ShapeStats { pub emitted: usize, pub dropped_no_asset: usize, /// Consumable/staff items excluded from a player projection (they must never /// render as a 0-rated player). Counted, never emitted. pub excluded_non_player: usize, } /// Quick-sell / discard value by rating tier (mirrors Core's quick-sell table; /// non-fatal display field). fn discard_value(rating: u8) -> i64 { match rating { r if r >= 85 => 1500, r if r >= 80 => 900, r if r >= 75 => 600, r if r >= 65 => 300, _ => 150, } } /// Build one FIFA `_item` object. `resourceId`/`definitionId` carry the full /// versioned resource id; `assetId`/`cardassetid` carry the base asset. For a /// version-0 base card these coincide; for a special they differ and MUST NOT /// be collapsed. /// /// This is the single source of truth for a player item's on-wire shape; the /// `/club` envelope and squad projection both call it, so their items are /// identical by construction. `id` is the owned instance's resolved FIFA /// identity — pass the resolver's answer for *this* owned copy so two copies of /// one definition stay distinct on the wire. pub fn shape_item( item: &CoreOwnedItem, id: Fifa17Identity, ent: &impl ReverseEntityResolver, ) -> Value { let asset = id.asset_id; let league_id = ent.league_id(&item.league).unwrap_or(0); let team_id = ent.team_id(&item.club).unwrap_or(0); let nation_id = ent.nation_id(&item.nation).unwrap_or(0); let attribute_list: Vec = item .attributes .iter() .enumerate() .map(|(i, v)| json!({ "index": i, "value": v })) .collect(); json!({ "id": id.item_id, "resourceId": id.resource_id, "assetId": asset, "cardassetid": asset, "definitionId": id.resource_id, "cardsubtypeid": 0, "itemType": "player", "rareflag": id.rareflag, "rating": item.rating, "preferredPosition": item.position, "nation": nation_id, "teamid": team_id, "leagueId": league_id, "playStyle": 250, "attributeList": attribute_list, "itemState": "free", "owners": 1, // Owned/pack-pulled cards are TRADEABLE in FIFA 17 (untradeable is the // exception for SBC/promo rewards, which Core does not model). Emitting // `true` greyed out "Place on Transfer Market" for every card — the same // "our own data showing through" bug the Python oracle fixed by forcing // this off for owned copies (item_def keeps `true`; instances do not). "untradeable": false, "contract": 7, "fitness": 99, "discardValue": discard_value(item.rating), }) } #[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) -> CoreOwnedItem { CoreOwnedItem { owned_card_id: owned.into(), card_id: card.into(), rating, position: pos.into(), nation: "Argentina".into(), league: "Premier League".into(), club: "Chelsea".into(), attributes: [90, 88, 70, 85, 40, 78], } } #[test] fn shapes_real_identity_and_reverse_entity_ids() { let ent = entities(); let it = shape_item( &item("oc1", "card_ch_1", 86, "CDM"), Fifa17Identity { item_id: 100000001, asset_id: 20801, resource_id: 20801, rareflag: 1, }, &ent, ); assert_eq!(it["id"], 100000001, "wire instance id"); 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["attributeList"].as_array().unwrap().len(), 6); assert_eq!(it["attributeList"][0], json!({"index":0,"value":90})); } #[test] fn two_owned_copies_of_one_definition_stay_distinct_on_the_wire() { // fifa17_101490 has two owned instances: same definition/asset, two // distinct owned ids and two distinct wire ids. Shaping each from its // own identity must NEVER collapse them. let ent = entities(); let a = shape_item( &item("oc-a", "fifa17_101490", 84, "ST"), Fifa17Identity { item_id: 100000030, asset_id: 101490, resource_id: 101490, rareflag: 1, }, &ent, ); let b = shape_item( &item("oc-b", "fifa17_101490", 84, "ST"), Fifa17Identity { item_id: 100000031, asset_id: 101490, resource_id: 101490, rareflag: 1, }, &ent, ); assert_eq!( a["resourceId"], b["resourceId"], "same definition => same asset" ); assert_ne!( a["id"], b["id"], "distinct owned copies keep distinct wire ids" ); assert_eq!(a["id"], 100000030); assert_eq!(b["id"], 100000031); } #[test] fn versioned_special_keeps_resourceid_distinct_from_assetid() { // A versioned (special) card: resourceId/definitionId carry the full // versioned id; assetId/cardassetid stay the base asset. They MUST NOT // collapse. (resource 117617092 = version 7 of asset 176580.) let ent = entities(); let it = shape_item( &item("oc-v", "fifa17_117617092", 92, "ST"), Fifa17Identity { item_id: 100000384, asset_id: 176580, resource_id: 117617092, rareflag: 3, }, &ent, ); assert_eq!( it["resourceId"], 117617092, "versioned resource id on the wire" ); assert_eq!(it["definitionId"], 117617092); assert_eq!(it["assetId"], 176580, "base asset id preserved"); assert_eq!(it["cardassetid"], 176580); assert_eq!( it["rareflag"], 3, "special rareflag carried, not hardcoded 1" ); } }