//! FIFA 17 Squad Building Challenge wire shapes. //! //! Numeric category/set/challenge ids and container types mirror the reversed FIFA 17 //! `/sbs/*` family. Core ids remain opaque strings and are mapped here, never in Core. use serde_json::{json, Value}; pub const CATEGORY_ID: i64 = 1; pub const CATEGORY_NAME: &str = "Foundations"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ChallengeIdentity { pub core_id: &'static str, pub set_id: i64, pub challenge_id: i64, pub priority: i64, } pub const CHALLENGES: [ChallengeIdentity; 2] = [ ChallengeIdentity { core_id: "sbc_bronze_upgrade", set_id: 1, challenge_id: 101, priority: 1, }, ChallengeIdentity { core_id: "sbc_hybrid_nations", set_id: 2, challenge_id: 201, priority: 2, }, ]; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ChallengeView { pub identity: ChallengeIdentity, pub name: String, pub description: String, pub repeatable: bool, pub times_completed: i64, } pub fn identity_for_core(core_id: &str) -> Option { CHALLENGES .iter() .copied() .find(|entry| entry.core_id == core_id) } pub fn identity_for_challenge(challenge_id: i64) -> Option { CHALLENGES .iter() .copied() .find(|entry| entry.challenge_id == challenge_id) } /// `GET sbs/sets`: object root; categories, sets and awards are always arrays. pub fn sets_body(challenges: &[ChallengeView]) -> Value { let sets: Vec = challenges .iter() .map(|challenge| { json!({ "setId": challenge.identity.set_id, "categoryId": CATEGORY_ID, "name": challenge.name, "description": challenge.description, "priority": challenge.identity.priority, "challengesCount": 1, "challengesCompletedCount": i64::from(!challenge.repeatable && challenge.times_completed > 0), "awards": [], "hidden": false, "endTime": 4_102_444_800_i64 }) }) .collect(); json!({ "categories": [{ "categoryId": CATEGORY_ID, "name": CATEGORY_NAME, "priority": 1, "sets": sets }] }) } /// `GET sbs/setId/{id}/challenges`: object root with a challenge array. pub fn challenges_body(set_id: i64, challenges: &[ChallengeView]) -> Value { let records: Vec = challenges .iter() .filter(|challenge| challenge.identity.set_id == set_id) .map(|challenge| { // A repeatable challenge is always available to enter again. The FIFA 17 // client gates challenge re-entry on `timesCompleted` (not on `repeatable`): // a nonzero count renders the tile COMPLETED and refuses re-entry. So a // repeatable challenge never reports itself as terminally completed here. // Core keeps the true completion record (economy authority); this is // presentation only. Live-proven on the retail client 2026-08-18. let times_completed = if challenge.repeatable { 0 } else { challenge.times_completed }; json!({ "challengeId": challenge.identity.challenge_id, "setId": challenge.identity.set_id, "categoryId": CATEGORY_ID, "index": 0, "type": "OPEN_CHALLENGE", "name": challenge.name, "description": challenge.description, "challengeImageId": "", "formation": "f442", "endTime": 0, "repeatable": challenge.repeatable, "trophyId": 0, "status": "OPEN", "timesCompleted": times_completed, "awards": [], "elgReq": [] }) }) .collect(); json!({ "challenges": records }) } /// Empty-body POST starts a challenge. `squad` is object-root on this response class. pub fn start_body(challenge_id: i64) -> Value { json!({ "challengeId": challenge_id, "squad": {}, "playerRequirements": [] }) } /// GET challenge squad. The reversed response requires array containers. pub fn squad_body(challenge_id: i64, wire_item_ids: &[i64]) -> Value { let squad: Vec = wire_item_ids .iter() .enumerate() .map(|(index, id)| { json!({ "index": index, "itemData": { "id": id }, "kitNumber": 0 }) }) .collect(); json!({ "id": challenge_id, "squad": squad, "playerRequirements": [] }) } pub fn save_body(challenge_id: i64) -> Value { json!({ "id": challenge_id }) } pub fn submit_body(challenge_id: i64, set_id: i64, credits: i64, unopened_packs: i64) -> Value { json!({ "challengeId": challenge_id, "setId": set_id, "credits": credits, "preOrderPacks": 0, "recoveredPacks": unopened_packs, "grantedChallengeAwards": [], "grantedSetAwards": [] }) } #[derive(Debug, PartialEq, Eq)] pub enum SbcWireError { Json(String), MissingSquad, } impl std::fmt::Display for SbcWireError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Json(error) => write!(formatter, "invalid SBC squad JSON: {error}"), Self::MissingSquad => { formatter.write_str("SBC squad body contains no supported squad container") } } } } impl std::error::Error for SbcWireError {} /// Extract FIFA wire item ids from a saved/submitted challenge squad. /// /// The exact retail challenge-squad body is now captured (2026-08-18 Gate C, /// `PUT /ut/game/fifa17/sbs/challenge/101/squad`): a fixed 23-entry `players` /// array of `{index, itemData:{id, dream}}` (empty slots carry `id == 0`), /// alongside sibling `chemistry`, `rating`, `formation`, and a `manager` array /// of `{id, dream}`. Only `players[].itemData.id` selects the consumed cards; /// the manager, chemistry, rating, formation, dream, and index fields are /// presentation/validation hints and are never consumed. The `squad` array /// container remains accepted for the alternate proven shape. Within either, /// only non-zero `itemData.id` values are interpreted. pub fn parse_wire_item_ids(body: &[u8]) -> Result, SbcWireError> { let root: Value = serde_json::from_slice(body).map_err(|error| SbcWireError::Json(error.to_string()))?; let entries = root .get("players") .and_then(Value::as_array) .or_else(|| root.get("squad").and_then(Value::as_array)) .ok_or(SbcWireError::MissingSquad)?; let mut ids = Vec::new(); for entry in entries { if let Some(id) = entry .get("itemData") .and_then(|item| item.get("id")) .and_then(Value::as_i64) .filter(|id| *id != 0) { ids.push(id); } } Ok(ids) } #[cfg(test)] mod tests { use super::*; fn challenge() -> ChallengeView { ChallengeView { identity: CHALLENGES[0], name: "Bronze Upgrade".into(), description: "Submit players".into(), repeatable: true, times_completed: 2, } } #[test] fn response_containers_match_reversed_fifa17_shapes() { let sets = sets_body(&[challenge()]); assert!(sets.is_object()); assert!(sets["categories"].is_array()); assert!(sets["categories"][0]["sets"].is_array()); assert!(sets["categories"][0]["sets"][0]["awards"].is_array()); let challenges = challenges_body(1, &[challenge()]); assert!(challenges.is_object()); assert!(challenges["challenges"].is_array()); assert!(challenges["challenges"][0]["awards"].is_array()); assert!(challenges["challenges"][0]["elgReq"].is_array()); assert!(start_body(101)["squad"].is_object()); assert!(start_body(101)["playerRequirements"].is_array()); assert!(squad_body(101, &[100_000_001])["squad"].is_array()); let submit = submit_body(101, 1, 1234, 1); assert!(submit["grantedChallengeAwards"].is_array()); assert!(submit["grantedSetAwards"].is_array()); } #[test] fn repeatable_completed_challenge_stays_enterable() { // The FIFA 17 client refuses challenge re-entry when timesCompleted > 0, so // a repeatable challenge must always project as not-yet-completed while a // non-repeatable one keeps its true count. Core holds the real record. let repeatable = ChallengeView { identity: CHALLENGES[0], name: "Bronze Upgrade".into(), description: "Submit players".into(), repeatable: true, times_completed: 3, }; let once = ChallengeView { identity: CHALLENGES[1], name: "Hybrid Nations".into(), description: "Submit a hybrid squad".into(), repeatable: false, times_completed: 1, }; let repeatable_view = challenges_body(CHALLENGES[0].set_id, &[repeatable.clone()]); assert_eq!(repeatable_view["challenges"][0]["timesCompleted"], 0); assert_eq!(repeatable_view["challenges"][0]["status"], "OPEN"); let once_view = challenges_body(CHALLENGES[1].set_id, &[once.clone()]); assert_eq!(once_view["challenges"][0]["timesCompleted"], 1); let sets = sets_body(&[repeatable, once]); let sets_arr = sets["categories"][0]["sets"].as_array().unwrap(); let repeatable_set = sets_arr .iter() .find(|set| set["setId"] == CHALLENGES[0].set_id) .unwrap(); let once_set = sets_arr .iter() .find(|set| set["setId"] == CHALLENGES[1].set_id) .unwrap(); assert_eq!( repeatable_set["challengesCompletedCount"], 0, "a repeatable set never reports itself terminally completed" ); assert_eq!( once_set["challengesCompletedCount"], 1, "a one-shot set counts its single completion" ); } #[test] fn parser_accepts_only_known_squad_containers_and_item_ids() { let normal = br#"{"players":[{"index":0,"itemData":{"id":100000001}},{"index":1,"itemData":{"id":0}}]}"#; assert_eq!(parse_wire_item_ids(normal).unwrap(), [100_000_001]); let sbc = br#"{"squad":[{"itemData":{"id":100000002}}]}"#; assert_eq!(parse_wire_item_ids(sbc).unwrap(), [100_000_002]); assert_eq!( parse_wire_item_ids(br#"{"challengeId":101}"#), Err(SbcWireError::MissingSquad) ); assert!(matches!( parse_wire_item_ids(br#"{"squad":"not-an-array"}"#), Err(SbcWireError::MissingSquad) )); assert!(matches!( parse_wire_item_ids(br#"{"squad":["#), Err(SbcWireError::Json(_)) )); } #[test] fn parser_matches_captured_retail_challenge_squad_body() { // Verbatim shape from the 2026-08-18 Gate C retail capture of // PUT /ut/game/fifa17/sbs/challenge/101/squad (11 filled + 12 empty // slots, plus manager/chemistry/rating/formation siblings). Only the 11 // non-zero player itemData.id values are consumed, in wire order; the // manager and all presentation fields are ignored. let retail = br#"{"chemistry":21,"rating":86,"formation":"f433", "manager":[{"id":100000427,"dream":false}], "players":[ {"index":0,"itemData":{"id":100004227,"dream":false}}, {"index":1,"itemData":{"id":100004233,"dream":false}}, {"index":2,"itemData":{"id":100001317,"dream":false}}, {"index":3,"itemData":{"id":100001531,"dream":false}}, {"index":4,"itemData":{"id":100000966,"dream":false}}, {"index":5,"itemData":{"id":100001947,"dream":false}}, {"index":6,"itemData":{"id":100000169,"dream":false}}, {"index":7,"itemData":{"id":100002017,"dream":false}}, {"index":8,"itemData":{"id":100002765,"dream":false}}, {"index":9,"itemData":{"id":100000147,"dream":false}}, {"index":10,"itemData":{"id":100000311,"dream":false}}, {"index":11,"itemData":{"id":0,"dream":false}}, {"index":12,"itemData":{"id":0,"dream":false}}, {"index":13,"itemData":{"id":0,"dream":false}}, {"index":14,"itemData":{"id":0,"dream":false}}, {"index":15,"itemData":{"id":0,"dream":false}}, {"index":16,"itemData":{"id":0,"dream":false}}, {"index":17,"itemData":{"id":0,"dream":false}}, {"index":18,"itemData":{"id":0,"dream":false}}, {"index":19,"itemData":{"id":0,"dream":false}}, {"index":20,"itemData":{"id":0,"dream":false}}, {"index":21,"itemData":{"id":0,"dream":false}}, {"index":22,"itemData":{"id":0,"dream":false}} ]}"#; assert_eq!( parse_wire_item_ids(retail).unwrap(), [ 100_004_227, 100_004_233, 100_001_317, 100_001_531, 100_000_966, 100_001_947, 100_000_169, 100_002_017, 100_002_765, 100_000_147, 100_000_311 ], "exactly the 11 non-zero players in wire order; manager and empty slots ignored" ); } }