f56aa613da
The FIFA 17 client gates challenge re-entry on timesCompleted, not on the repeatable flag: a nonzero count renders the tile COMPLETED and refuses re-entry even when repeatable=true. So a repeatable challenge now always projects timesCompleted=0 (challenges_body) and its set as challengesCompletedCount=0 (sets_body); a non-repeatable challenge keeps its true count and stays locked once completed. Core keeps the authoritative completion record — economy is unaffected; this is presentation only. Live-proven on the retail client 2026-08-18: a completed repeatable Bronze Upgrade now re-opens for a fresh submit instead of blocking. Regression test repeatable_completed_challenge_stays_enterable added; adapter + full host suite (incl. differential and the concurrency race) green.
371 lines
14 KiB
Rust
371 lines
14 KiB
Rust
//! 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<ChallengeIdentity> {
|
|
CHALLENGES
|
|
.iter()
|
|
.copied()
|
|
.find(|entry| entry.core_id == core_id)
|
|
}
|
|
|
|
pub fn identity_for_challenge(challenge_id: i64) -> Option<ChallengeIdentity> {
|
|
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<Value> = 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<Value> = 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<Value> = 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<Vec<i64>, 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"
|
|
);
|
|
}
|
|
}
|