Files
OpenFUT/openfut-adapter-fifa17/src/fut/sbc.rs
T
funman300 f56aa613da fifa17 SBC: keep repeatable challenges re-enterable after completion
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.
2026-08-19 01:18:36 +00:00

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"
);
}
}