diff --git a/Cargo.lock b/Cargo.lock index 9ef2812..22e8817 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3210,6 +3210,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "tempfile", "thiserror 1.0.69", "tokio", "tower 0.5.3", diff --git a/openfut-adapter-fifa17/src/fut/mod.rs b/openfut-adapter-fifa17/src/fut/mod.rs index 0e1c98e..3be4daa 100644 --- a/openfut-adapter-fifa17/src/fut/mod.rs +++ b/openfut-adapter-fifa17/src/fut/mod.rs @@ -15,6 +15,7 @@ pub mod item; pub mod non_economy; pub mod owned_query; pub mod pack_content; +pub mod sbc; pub mod squad; pub mod squad_ext; pub mod squad_projection; diff --git a/openfut-adapter-fifa17/src/fut/sbc.rs b/openfut-adapter-fifa17/src/fut/sbc.rs new file mode 100644 index 0000000..1bde9fe --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/sbc.rs @@ -0,0 +1,254 @@ +//! 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.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| { + 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": challenge.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. +/// +/// Retail request captures for this body are unavailable. The parser therefore accepts +/// only the two already-proven FIFA 17 squad containers: a normal `players` array or the +/// SBC `squad` array. Within either, only `itemData.id` is 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 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) + ); + } +} diff --git a/openfut-core b/openfut-core index 637a21e..271c363 160000 --- a/openfut-core +++ b/openfut-core @@ -1 +1 @@ -Subproject commit 637a21eac12a2b3c163ea754ab220417b1528761 +Subproject commit 271c3639edbce1fca856454385e64e1ffe877811 diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 494a120..f22d911 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -64,6 +64,7 @@ use openfut_adapter_fifa17::fut::owned_query::{ is_special_rareflag, map_to_core, parse_club_query, MapError, }; use openfut_adapter_fifa17::fut::pack_content::GeneratedCandidate; +use openfut_adapter_fifa17::fut::sbc as fifa17_sbc; use openfut_adapter_fifa17::fut::squad::{parse_squad_put, save_ack, SquadWireResolver}; use openfut_adapter_fifa17::fut::squad_ext::{ build_squad_write, Fifa17SquadExtensionV1, SquadBuildError, EXT_NAMESPACE, EXT_SCHEMA_VERSION, @@ -369,6 +370,16 @@ pub enum EconomyRoute { /// [`EconomyRoute::MarketCancel`], which would parse no id and ack while /// clearing nothing. MarketClearSold, + /// `GET …/sbs/sets` — SBC category/set list from Core definitions. + SbcSets, + /// `POST/PUT …/sbs/sets/tag` — stateless tag acknowledgement. + SbcTag, + /// `GET …/sbs/setId//challenges` — challenges in one set. + SbcChallenges, + /// `GET/PUT …/sbs/challenge//squad` — durable working squad. + SbcChallengeSquad, + /// `POST/PUT …/sbs/challenge/` — start or atomic submission. + SbcChallenge, } /// `item/` — the single-card quick-sell tail (DELETE). @@ -442,6 +453,27 @@ fn is_trade_status_tail(tail: &str) -> bool { fn is_trade_sold_tail(tail: &str) -> bool { tail.eq_ignore_ascii_case("trade/sold") } +fn bounded_numeric_segment<'a>(tail: &'a str, prefix: &str, suffix: &str) -> Option<&'a str> { + let value = tail.strip_prefix(prefix)?.strip_suffix(suffix)?; + if !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) { + Some(value) + } else { + None + } +} + +fn sbc_challenge_id(tail: &str) -> Option { + bounded_numeric_segment(tail, "sbs/challenge/", "") + .or_else(|| bounded_numeric_segment(tail, "sbs/challenge/", "/squad"))? + .parse() + .ok() +} + +fn sbc_set_id(tail: &str) -> Option { + bounded_numeric_segment(tail, "sbs/setId/", "/challenges")? + .parse() + .ok() +} /// Classify a FIFA17 economy route from method + path, mirroring the Python /// oracle's route table (`utas_server.py` §1418-1553). Returns `None` for any @@ -497,6 +529,17 @@ pub fn classify_economy(method: &str, path: &str) -> Option { // auctionInfo), and a plain DELETE fell in as a no-op "view". Some(t) if get && is_trade_status_tail(t) => Some(EconomyRoute::MarketStatus), Some(t) if delete && t.starts_with("trade") => Some(EconomyRoute::MarketCancel), + Some("sbs/sets") if get => Some(EconomyRoute::SbcSets), + Some("sbs/sets/tag") if post || put => Some(EconomyRoute::SbcTag), + Some(t) if get && sbc_set_id(t).is_some() => Some(EconomyRoute::SbcChallenges), + Some(t) + if (get || put) && bounded_numeric_segment(t, "sbs/challenge/", "/squad").is_some() => + { + Some(EconomyRoute::SbcChallengeSquad) + } + Some(t) if (post || put) && bounded_numeric_segment(t, "sbs/challenge/", "").is_some() => { + Some(EconomyRoute::SbcChallenge) + } Some(t) if t.starts_with("trade") => Some(EconomyRoute::MarketBuy), _ => None, } @@ -595,6 +638,20 @@ pub struct CoreReplaceResult { /// How the host reaches Core. The adapter never sees this — the host owns the /// transport, mirroring the architecture rule. Tests inject a fake. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoreSbcDefinition { + pub id: String, + pub name: String, + pub description: String, + pub repeatable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoreSbcResult { + pub passed: bool, + pub failures: Vec, +} + pub trait CoreAccess: Send + Sync { /// Query the owned inventory with semantic `/collection` query params. fn query_owned(&self, params: &[(&str, String)]) -> Result; @@ -611,6 +668,44 @@ pub trait CoreAccess: Send + Sync { /// Replace the active squad's canonical slots + opaque extension atomically. fn replace_squad(&self, req: &CoreReplaceRequest) -> Result; + + fn list_sbcs(&self) -> Result, CoreError> { + Err(CoreError::Parse( + "Core SBC access is not implemented".into(), + )) + } + + fn sbc_completion_counts(&self) -> Result, CoreError> { + Err(CoreError::Parse( + "Core SBC access is not implemented".into(), + )) + } + + fn load_sbc_squad(&self, _sbc_id: &str) -> Result, CoreError> { + Err(CoreError::Parse( + "Core SBC access is not implemented".into(), + )) + } + + fn save_sbc_squad( + &self, + _sbc_id: &str, + _owned_card_ids: &[String], + ) -> Result, CoreError> { + Err(CoreError::Parse( + "Core SBC access is not implemented".into(), + )) + } + + fn submit_sbc( + &self, + _sbc_id: &str, + _owned_card_ids: &[String], + ) -> Result { + Err(CoreError::Parse( + "Core SBC access is not implemented".into(), + )) + } } /// Default HTTP implementation: `GET {core_url}/collection?…` (plain HTTP JSON, @@ -696,6 +791,150 @@ impl CoreAccess for HttpCoreClient { slots_written: v.get("slots_written").and_then(|x| x.as_u64()).unwrap_or(0) as usize, }) } + + fn list_sbcs(&self) -> Result, CoreError> { + let response = self + .client + .get(format!("{}/sbc", self.base_url)) + .header("X-OpenFUT-Game", &self.game) + .send() + .map_err(|error| CoreError::Http(error.to_string()))?; + let status = response.status().as_u16(); + if !(200..300).contains(&status) { + return Err(CoreError::Status(status)); + } + let body: Value = response + .json() + .map_err(|error| CoreError::Parse(error.to_string()))?; + body.get("sbcs") + .and_then(Value::as_array) + .ok_or_else(|| CoreError::Parse("missing `sbcs` array".into()))? + .iter() + .map(|definition| { + Ok(CoreSbcDefinition { + id: json_str(definition, "id")?, + name: json_str(definition, "name")?, + description: json_str(definition, "description")?, + repeatable: definition + .get("repeatable") + .and_then(Value::as_bool) + .unwrap_or(false), + }) + }) + .collect() + } + + fn sbc_completion_counts(&self) -> Result, CoreError> { + let response = self + .client + .get(format!("{}/sbc/status", self.base_url)) + .header("X-OpenFUT-Game", &self.game) + .send() + .map_err(|error| CoreError::Http(error.to_string()))?; + let status = response.status().as_u16(); + if !(200..300).contains(&status) { + return Err(CoreError::Status(status)); + } + let body: Value = response + .json() + .map_err(|error| CoreError::Parse(error.to_string()))?; + let completions = body + .get("completions") + .and_then(Value::as_object) + .ok_or_else(|| CoreError::Parse("missing `completions` object".into()))?; + completions + .iter() + .map(|(id, value)| { + value + .as_i64() + .map(|count| (id.clone(), count)) + .ok_or_else(|| CoreError::Parse(format!("invalid completion count for {id}"))) + }) + .collect() + } + + fn load_sbc_squad(&self, sbc_id: &str) -> Result, CoreError> { + let response = self + .client + .get(format!("{}/sbc/{sbc_id}/squad", self.base_url)) + .header("X-OpenFUT-Game", &self.game) + .send() + .map_err(|error| CoreError::Http(error.to_string()))?; + parse_core_sbc_squad_response(response) + } + + fn save_sbc_squad( + &self, + sbc_id: &str, + owned_card_ids: &[String], + ) -> Result, CoreError> { + let response = self + .client + .put(format!("{}/sbc/{sbc_id}/squad", self.base_url)) + .header("X-OpenFUT-Game", &self.game) + .json(&json!({ "owned_card_ids": owned_card_ids })) + .send() + .map_err(|error| CoreError::Http(error.to_string()))?; + parse_core_sbc_squad_response(response) + } + + fn submit_sbc( + &self, + sbc_id: &str, + owned_card_ids: &[String], + ) -> Result { + let response = self + .client + .post(format!("{}/sbc/submit", self.base_url)) + .header("X-OpenFUT-Game", &self.game) + .json(&json!({ "sbc_id": sbc_id, "owned_card_ids": owned_card_ids })) + .send() + .map_err(|error| CoreError::Http(error.to_string()))?; + let status = response.status().as_u16(); + if !(200..300).contains(&status) { + return Err(CoreError::Status(status)); + } + let body: Value = response + .json() + .map_err(|error| CoreError::Parse(error.to_string()))?; + Ok(CoreSbcResult { + passed: body + .get("passed") + .and_then(Value::as_bool) + .ok_or_else(|| CoreError::Parse("missing SBC `passed` bool".into()))?, + failures: body + .get("failures") + .and_then(Value::as_array) + .ok_or_else(|| CoreError::Parse("missing SBC `failures` array".into()))? + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + }) + } +} + +fn parse_core_sbc_squad_response( + response: reqwest::blocking::Response, +) -> Result, CoreError> { + let status = response.status().as_u16(); + if !(200..300).contains(&status) { + return Err(CoreError::Status(status)); + } + let body: Value = response + .json() + .map_err(|error| CoreError::Parse(error.to_string()))?; + body.get("squad") + .and_then(|squad| squad.get("owned_card_ids")) + .and_then(Value::as_array) + .ok_or_else(|| CoreError::Parse("missing SBC squad `owned_card_ids` array".into()))? + .iter() + .map(|id| { + id.as_str() + .map(str::to_owned) + .ok_or_else(|| CoreError::Parse("SBC owned-card id is not a string".into())) + }) + .collect() } // ───────────────────────────── Core economy boundary ──────────────────────── @@ -2202,6 +2441,10 @@ pub struct Server { /// [`Server::new`] gives each instance an ephemeral temp-file store; /// [`Server::from_config`] wires the configured durable path. clientdata: Arc, + /// Serializes host-owned pile/listing transitions with SBC eligibility checks. + /// Core supplies the database transaction; this gate closes the cross-store race + /// within one host process. + economy_gate: Arc>, } impl Server { @@ -2224,6 +2467,7 @@ impl Server { start: Instant::now(), economy: None, clientdata: Arc::new(ClientDataStore::open(ephemeral_clientdata_path())), + economy_gate: Arc::new(Mutex::new(())), } } @@ -2331,13 +2575,269 @@ impl Server { self } + fn sbc_views(&self) -> Result, CoreError> { + let definitions = self.core.list_sbcs()?; + let completions = self.core.sbc_completion_counts()?; + Ok(definitions + .into_iter() + .filter_map(|definition| { + let identity = fifa17_sbc::identity_for_core(&definition.id)?; + Some(fifa17_sbc::ChallengeView { + identity, + name: definition.name, + description: definition.description, + repeatable: definition.repeatable, + times_completed: completions.get(&definition.id).copied().unwrap_or(0), + }) + }) + .collect()) + } + + fn sbc_core_ids_from_wire( + &self, + service: &EconomyServices, + wire_ids: &[i64], + ) -> Result, WireResponse> { + let owned: std::collections::HashSet = self + .core + .all_owned() + .map_err(core_sbc_error_response)? + .into_iter() + .map(|item| item.owned_card_id) + .collect(); + let mut seen = std::collections::HashSet::with_capacity(wire_ids.len()); + let mut core_ids = Vec::with_capacity(wire_ids.len()); + for wire_id in wire_ids { + if !seen.insert(*wire_id) { + return Err(json_status( + 400, + &json!({ "error": format!("duplicate SBC item id {wire_id}") }), + )); + } + let core_id = self.resolver.owned_id_for_wire(*wire_id).ok_or_else(|| { + json_status( + 404, + &json!({ "error": format!("unknown SBC item id {wire_id}") }), + ) + })?; + if !owned.contains(&core_id) { + return Err(json_status( + 404, + &json!({ "error": format!("SBC item {wire_id} is not owned") }), + )); + } + self.ensure_sbc_host_eligible(service, &core_id)?; + core_ids.push(core_id); + } + Ok(core_ids) + } + + fn ensure_sbc_host_eligible( + &self, + service: &EconomyServices, + core_id: &str, + ) -> Result<(), WireResponse> { + let piles = service.piles.clone(); + let id = core_id.to_owned(); + let pile = service + .bridge + .block_on(async move { piles.get(&id).await }) + .map_err(|error| { + json_status( + 503, + &json!({ "error": format!("SBC pile eligibility unavailable: {error}") }), + ) + })?; + if matches!(pile.as_deref(), Some("purchased" | "unassigned")) { + return Err(json_status( + 409, + &json!({ "error": "purchased/unassigned items are not SBC-eligible" }), + )); + } + + let market = service.market.clone(); + let id = core_id.to_owned(); + let listed = service + .bridge + .block_on(async move { market.has_active_for_core_item(&id).await }) + .map_err(|error| { + json_status( + 503, + &json!({ "error": format!("SBC listing eligibility unavailable: {error}") }), + ) + })?; + if listed { + return Err(json_status( + 409, + &json!({ "error": "actively listed items are not SBC-eligible" }), + )); + } + Ok(()) + } + + fn sbc_wire_ids_from_core(&self, core_ids: &[String]) -> Result, WireResponse> { + let owned: std::collections::HashMap = self + .core + .all_owned() + .map_err(core_sbc_error_response)? + .into_iter() + .map(|item| (item.owned_card_id.clone(), item)) + .collect(); + core_ids + .iter() + .map(|core_id| { + let item = owned.get(core_id).ok_or_else(|| { + json_status(409, &json!({ "error": "saved SBC squad is stale" })) + })?; + self.resolver + .resolve(item) + .map(|identity| i64::from(identity.item_id)) + .ok_or_else(|| { + json_status( + 409, + &json!({ "error": "saved SBC item has no FIFA identity" }), + ) + }) + }) + .collect() + } + + fn handle_sbc_route( + &self, + service: &EconomyServices, + route: EconomyRoute, + method: &str, + path: &str, + body: &[u8], + ) -> WireResponse { + match route { + EconomyRoute::SbcSets => match self.sbc_views() { + Ok(views) => json_status(200, &fifa17_sbc::sets_body(&views)), + Err(error) => core_sbc_error_response(error), + }, + EconomyRoute::SbcTag => json_status(200, &json!({})), + EconomyRoute::SbcChallenges => { + let Some(set_id) = ut_tail(path).and_then(sbc_set_id) else { + return json_status(400, &json!({ "error": "invalid SBC set id" })); + }; + match self.sbc_views() { + Ok(views) => json_status(200, &fifa17_sbc::challenges_body(set_id, &views)), + Err(error) => core_sbc_error_response(error), + } + } + EconomyRoute::SbcChallengeSquad => { + let Some(challenge_id) = ut_tail(path).and_then(sbc_challenge_id) else { + return json_status(400, &json!({ "error": "invalid SBC challenge id" })); + }; + let Some(identity) = fifa17_sbc::identity_for_challenge(challenge_id) else { + return json_status(404, &json!({ "error": "unknown SBC challenge" })); + }; + if method.eq_ignore_ascii_case("GET") { + let core_ids = match self.core.load_sbc_squad(identity.core_id) { + Ok(ids) => ids, + Err(error) => return core_sbc_error_response(error), + }; + let wire_ids = match self.sbc_wire_ids_from_core(&core_ids) { + Ok(ids) => ids, + Err(response) => return response, + }; + return json_status(200, &fifa17_sbc::squad_body(challenge_id, &wire_ids)); + } + let wire_ids = match fifa17_sbc::parse_wire_item_ids(body) { + Ok(ids) => ids, + Err(error) => return json_status(400, &json!({ "error": error.to_string() })), + }; + let core_ids = match self.sbc_core_ids_from_wire(service, &wire_ids) { + Ok(ids) => ids, + Err(response) => return response, + }; + match self.core.save_sbc_squad(identity.core_id, &core_ids) { + Ok(_) => json_status(200, &fifa17_sbc::save_body(challenge_id)), + Err(error) => core_sbc_error_response(error), + } + } + EconomyRoute::SbcChallenge => { + let Some(challenge_id) = ut_tail(path).and_then(sbc_challenge_id) else { + return json_status(400, &json!({ "error": "invalid SBC challenge id" })); + }; + let Some(identity) = fifa17_sbc::identity_for_challenge(challenge_id) else { + return json_status(404, &json!({ "error": "unknown SBC challenge" })); + }; + if method.eq_ignore_ascii_case("POST") && body.is_empty() { + return json_status(200, &fifa17_sbc::start_body(challenge_id)); + } + + let core_ids = match fifa17_sbc::parse_wire_item_ids(body) { + Ok(wire_ids) => match self.sbc_core_ids_from_wire(service, &wire_ids) { + Ok(ids) => ids, + Err(response) => return response, + }, + Err(fifa17_sbc::SbcWireError::MissingSquad) => { + let ids = match self.core.load_sbc_squad(identity.core_id) { + Ok(ids) => ids, + Err(error) => return core_sbc_error_response(error), + }; + let wire_ids = match self.sbc_wire_ids_from_core(&ids) { + Ok(ids) => ids, + Err(response) => return response, + }; + match self.sbc_core_ids_from_wire(service, &wire_ids) { + Ok(ids) => ids, + Err(response) => return response, + } + } + Err(error) => return json_status(400, &json!({ "error": error.to_string() })), + }; + let result = match self.core.submit_sbc(identity.core_id, &core_ids) { + Ok(result) => result, + Err(error) => return core_sbc_error_response(error), + }; + if !result.passed { + return json_status( + 400, + &json!({ "error": "SBC requirements not met", "failures": result.failures }), + ); + } + + for core_id in &core_ids { + let piles = service.piles.clone(); + let id = core_id.clone(); + if let Err(error) = service + .bridge + .block_on(async move { piles.remove(&id).await }) + { + eprintln!( + "utas-host WARN SBC stale pile cleanup core_id={core_id} error={error}" + ); + } + } + let credits = match service.econ.balance() { + Ok(balance) => balance, + Err(error) => return core_sbc_error_response(error), + }; + let unopened_packs = match service.econ.entitlements() { + Ok(packs) => packs.len() as i64, + Err(error) => return core_sbc_error_response(error), + }; + json_status( + 200, + &fifa17_sbc::submit_body( + challenge_id, + identity.set_id, + credits, + unopened_packs, + ), + ) + } + _ => json_status(500, &json!({ "error": "invalid SBC route dispatch" })), + } + } + /// Dispatch a FIFA17 economy route to its Rust handler, or `None` if the path - /// is not an economy route (or no economy services are wired). This is the - /// handler-wiring entry point exercised by the integration harness; it is - /// deliberately NOT called by `handle_with_ip` yet — handler wiring and the - /// authority cutover are distinct steps, and the classifier barrier is one - /// later coherent flip. Sync handlers run inline; the async transfer-market / - /// move handlers run on the shared runtime via the bridge. + /// is not an economy route (or no economy services are wired). `handle_with_ip` + /// calls this before generic route classification, so matched routes never fall + /// through to Python. Sync handlers run inline; async store/market handlers use + /// the shared runtime bridge. pub fn try_handle_economy( &self, method: &str, @@ -2347,6 +2847,7 @@ impl Server { client_ip: Option<&str>, ) -> Option { let svc = self.economy.as_ref()?; + let _economy_guard = self.economy_gate.lock().unwrap(); let path = target.split('?').next().unwrap_or(target); let route = classify_economy(method, path)?; use crate::economy_store::{ @@ -2532,6 +3033,13 @@ impl Server { crate::market::handle_market_cancel(&p, owner.as_deref(), market.as_ref()).await }) } + route @ (EconomyRoute::SbcSets + | EconomyRoute::SbcTag + | EconomyRoute::SbcChallenges + | EconomyRoute::SbcChallengeSquad + | EconomyRoute::SbcChallenge) => { + self.handle_sbc_route(svc.as_ref(), route, method, path, body) + } EconomyRoute::MarketClearSold => { let (bridge, market) = (svc.bridge.clone(), svc.market.clone()); bridge.block_on(async move { @@ -3308,6 +3816,19 @@ fn json_status(status: u16, v: &Value) -> WireResponse { } } +fn core_sbc_error_response(error: CoreError) -> WireResponse { + let status = match &error { + CoreError::Status(400) => 400, + CoreError::Status(404) => 404, + CoreError::Status(409) => 409, + _ => 503, + }; + json_status( + status, + &json!({ "error": format!("Core SBC operation failed: {error}") }), + ) +} + /// A unique ephemeral JSON-state path used by [`Server::new`] tests. /// Production replaces both stores with configured durable paths. fn ephemeral_state_path(kind: &str) -> std::path::PathBuf { @@ -3478,6 +3999,7 @@ fn write_response(w: &mut W, resp: &WireResponse) -> std::io::Result<( #[cfg(test)] mod tests { use super::*; + use crate::async_bridge::AsyncBridge; /// A configurable in-memory economy double: real balance/entitlements, or a /// forced error to prove fail-closed behavior. @@ -4440,4 +4962,368 @@ mod tests { assert_eq!(obj["minPrice"], 150); assert_eq!(obj["maxPrice"], 15000); } + #[derive(Default)] + struct FakeSbcCore { + owned: Mutex>, + saved: Mutex>>, + completions: Mutex>, + } + + impl CoreAccess for FakeSbcCore { + fn query_owned(&self, _params: &[(&str, String)]) -> Result { + let items = self.owned.lock().unwrap().clone(); + Ok(CorePage { + total: items.len() as i64, + items, + }) + } + + fn read_squad_ext(&self, _namespace: &str) -> Result { + Err(CoreError::Status(501)) + } + + fn replace_squad( + &self, + _request: &CoreReplaceRequest, + ) -> Result { + Err(CoreError::Status(501)) + } + + fn list_sbcs(&self) -> Result, CoreError> { + Ok(vec![ + CoreSbcDefinition { + id: "sbc_bronze_upgrade".into(), + name: "Bronze Upgrade".into(), + description: "Submit two test players.".into(), + repeatable: true, + }, + CoreSbcDefinition { + id: "sbc_hybrid_nations".into(), + name: "Hybrid Nations".into(), + description: "Submit a hybrid squad.".into(), + repeatable: false, + }, + ]) + } + + fn sbc_completion_counts( + &self, + ) -> Result, CoreError> { + Ok(self.completions.lock().unwrap().clone()) + } + + fn load_sbc_squad(&self, sbc_id: &str) -> Result, CoreError> { + Ok(self + .saved + .lock() + .unwrap() + .get(sbc_id) + .cloned() + .unwrap_or_default()) + } + + fn save_sbc_squad( + &self, + sbc_id: &str, + owned_card_ids: &[String], + ) -> Result, CoreError> { + self.saved + .lock() + .unwrap() + .insert(sbc_id.to_owned(), owned_card_ids.to_vec()); + Ok(owned_card_ids.to_vec()) + } + + fn submit_sbc( + &self, + sbc_id: &str, + owned_card_ids: &[String], + ) -> Result { + if owned_card_ids.len() != 2 { + return Ok(CoreSbcResult { + passed: false, + failures: vec!["need exactly 2 cards".into()], + }); + } + let mut owned = self.owned.lock().unwrap(); + if owned_card_ids + .iter() + .any(|id| !owned.iter().any(|item| item.owned_card_id == *id)) + { + return Err(CoreError::Status(409)); + } + owned.retain(|item| !owned_card_ids.contains(&item.owned_card_id)); + self.saved.lock().unwrap().remove(sbc_id); + *self + .completions + .lock() + .unwrap() + .entry(sbc_id.to_owned()) + .or_default() += 1; + Ok(CoreSbcResult { + passed: true, + failures: vec![], + }) + } + } + + fn sbc_test_server() -> ( + Server, + Arc, + Arc, + Arc, + [i64; 2], + ) { + let first = owned("sbc-owned-1", "sbc-card-1"); + let second = owned("sbc-owned-2", "sbc-card-2"); + let core = Arc::new(FakeSbcCore { + owned: Mutex::new(vec![first.clone(), second.clone()]), + ..FakeSbcCore::default() + }); + let identity_path = temp_store_path("sbc-identity"); + let resolver = Arc::new(resolver( + &identity_path, + &[("sbc-card-1", 20_801), ("sbc-card-2", 20_802)], + )); + let wires = [ + i64::from(resolver.resolve(&first).unwrap().item_id), + i64::from(resolver.resolve(&second).unwrap().item_id), + ]; + let bridge = Arc::new(AsyncBridge::new().unwrap()); + let market_path = temp_store_path("sbc-market"); + let pile_path = temp_store_path("sbc-piles"); + let market = Arc::new( + bridge + .block_on(async move { + crate::market_store::MarketStore::open( + market_path.to_str().expect("UTF-8 temp market path"), + ) + .await + }) + .unwrap(), + ); + let piles = Arc::new( + bridge + .block_on(async move { + crate::pile_store::PileStore::open( + pile_path.to_str().expect("UTF-8 temp pile path"), + ) + .await + }) + .unwrap(), + ); + let services = Arc::new(EconomyServices { + econ: Arc::new(FakeEconomy::ok(1_100, 1)), + market, + piles, + bridge, + pool: Arc::new(vec![]), + sold_experiment: crate::sold_experiment::SoldExperiment::OFF, + }); + let server = Server::new( + core.clone(), + Arc::new(Fifa17Entities::default()), + resolver.clone(), + Arc::new(PassClient::new("http://127.0.0.1:9")), + 1, + ) + .with_economy(services.clone()); + (server, core, services, resolver, wires) + } + + #[test] + fn fifa17_sbc_route_family_is_bounded_and_rust_owned() { + let routes = [ + ("GET", "/ut/game/fifa17/sbs/sets", EconomyRoute::SbcSets), + ("PUT", "/ut/game/fifa17/sbs/sets/tag", EconomyRoute::SbcTag), + ( + "GET", + "/ut/game/fifa17/sbs/setId/1/challenges", + EconomyRoute::SbcChallenges, + ), + ( + "GET", + "/ut/game/fifa17/sbs/challenge/101/squad", + EconomyRoute::SbcChallengeSquad, + ), + ( + "PUT", + "/ut/game/fifa17/sbs/challenge/101/squad", + EconomyRoute::SbcChallengeSquad, + ), + ( + "POST", + "/ut/game/fifa17/sbs/challenge/101", + EconomyRoute::SbcChallenge, + ), + ( + "PUT", + "/ut/game/fifa17/sbs/challenge/101", + EconomyRoute::SbcChallenge, + ), + ]; + for (method, path, expected) in routes { + assert_eq!(classify_economy(method, path), Some(expected)); + } + assert_eq!( + classify_economy("GET", "/ut/game/fifa17/sbs/challenge/x/squad"), + None + ); + assert_eq!( + classify_economy("DELETE", "/ut/game/fifa17/sbs/challenge/101"), + None + ); + assert_eq!( + classify_economy("GET", "/ut/game/fifa17/sbs/sets/extra"), + None + ); + } + + #[test] + fn fifa17_sbc_flow_enforces_host_eligibility_and_consumes_once() { + let (server, core, services, resolver, wires) = sbc_test_server(); + let sets = server.handle("GET", "/ut/game/fifa17/sbs/sets", &[], b""); + assert_eq!(sets.status, 200); + let sets_body: Value = serde_json::from_slice(&sets.body).unwrap(); + assert!(sets_body["categories"].is_array()); + assert_eq!( + sets_body["categories"][0]["sets"].as_array().unwrap().len(), + 2 + ); + + let save_body = serde_json::to_vec(&json!({ + "players": [ + { "index": 0, "itemData": { "id": wires[0] } }, + { "index": 1, "itemData": { "id": wires[1] } } + ] + })) + .unwrap(); + + let piles = services.piles.clone(); + let bridge = services.bridge.clone(); + bridge + .block_on(async move { piles.set("sbc-owned-1", "purchased").await }) + .unwrap(); + let purchased = server.handle( + "PUT", + "/ut/game/fifa17/sbs/challenge/101/squad", + &[], + &save_body, + ); + assert_eq!(purchased.status, 409); + assert!(core.saved.lock().unwrap().is_empty()); + + let piles = services.piles.clone(); + services + .bridge + .block_on(async move { piles.set("sbc-owned-1", "unassigned").await }) + .unwrap(); + let unassigned = server.handle( + "PUT", + "/ut/game/fifa17/sbs/challenge/101/squad", + &[], + &save_body, + ); + assert_eq!(unassigned.status, 409); + assert!(core.saved.lock().unwrap().is_empty()); + + let piles = services.piles.clone(); + services + .bridge + .block_on(async move { piles.set("sbc-owned-1", "club").await }) + .unwrap(); + let market = services.market.clone(); + services + .bridge + .block_on(async move { + market + .create_listing( + "900000001", + "sbc-card-1", + Some("sbc-owned-1"), + Some(wires[0]), + Some(20_801), + 150, + 200, + Some("owner"), + None, + Some(3600), + ) + .await + }) + .unwrap(); + let listed = server.handle( + "PUT", + "/ut/game/fifa17/sbs/challenge/101/squad", + &[], + &save_body, + ); + assert_eq!(listed.status, 409); + let market = services.market.clone(); + services + .bridge + .block_on(async move { market.cancel_active_for_core_item("sbc-owned-1").await }) + .unwrap(); + + let saved = server.handle( + "PUT", + "/ut/game/fifa17/sbs/challenge/101/squad", + &[], + &save_body, + ); + assert_eq!(saved.status, 200); + let loaded = server.handle("GET", "/ut/game/fifa17/sbs/challenge/101/squad", &[], b""); + let loaded_body: Value = serde_json::from_slice(&loaded.body).unwrap(); + assert_eq!(loaded_body["squad"][0]["itemData"]["id"], wires[0]); + assert_eq!(loaded_body["squad"][1]["itemData"]["id"], wires[1]); + + let submitted = server.handle("PUT", "/ut/game/fifa17/sbs/challenge/101", &[], br#"{}"#); + assert_eq!(submitted.status, 200); + let submitted_body: Value = serde_json::from_slice(&submitted.body).unwrap(); + assert_eq!(submitted_body["challengeId"], 101); + assert_eq!(submitted_body["setId"], 1); + assert_eq!(submitted_body["credits"], 1_100); + assert_eq!(submitted_body["recoveredPacks"], 1); + assert!(submitted_body["grantedChallengeAwards"].is_array()); + assert!(core.owned.lock().unwrap().is_empty()); + assert_eq!( + core.completions + .lock() + .unwrap() + .get("sbc_bronze_upgrade") + .copied(), + Some(1) + ); + + for core_id in ["sbc-owned-1", "sbc-owned-2"] { + let piles = services.piles.clone(); + let id = core_id.to_owned(); + assert_eq!( + services + .bridge + .block_on(async move { piles.get(&id).await }) + .unwrap(), + None + ); + } + assert_eq!( + resolver.owned_id_for_wire(wires[0]).as_deref(), + Some("sbc-owned-1"), + "durable mapping remains, but no ownership-backed projection can emit it" + ); + let reveal = server.handle("GET", "/ut/v2/game/fifa17/purchased/items", &[], b""); + let reveal_body: Value = serde_json::from_slice(&reveal.body).unwrap(); + assert!(reveal_body["itemData"].as_array().unwrap().is_empty()); + + let replay = server.handle("PUT", "/ut/game/fifa17/sbs/challenge/101", &[], &save_body); + assert_eq!(replay.status, 404); + assert_eq!( + core.completions + .lock() + .unwrap() + .get("sbc_bronze_upgrade") + .copied(), + Some(1) + ); + } } diff --git a/openfut-utas-host/src/market_store.rs b/openfut-utas-host/src/market_store.rs index dd8c122..963f128 100644 --- a/openfut-utas-host/src/market_store.rs +++ b/openfut-utas-host/src/market_store.rs @@ -697,6 +697,18 @@ impl MarketStore { .map_err(db)? .rows_affected()) } + + pub async fn has_active_for_core_item(&self, core_item_id: &str) -> Result { + sqlx::query_scalar( + "SELECT EXISTS( \ + SELECT 1 FROM listings WHERE core_item_id = ? AND state = 'active' \ + )", + ) + .bind(core_item_id) + .fetch_one(&self.pool) + .await + .map_err(db) + } } #[cfg(test)] diff --git a/openfut-utas-host/src/pile_store.rs b/openfut-utas-host/src/pile_store.rs index 95b8d05..b018967 100644 --- a/openfut-utas-host/src/pile_store.rs +++ b/openfut-utas-host/src/pile_store.rs @@ -159,6 +159,16 @@ impl PileStore { .map(|r| r.get::("core_item_id")) .collect()) } + /// Remove stale presentation metadata after Core has consumed an item. + /// Projection paths still intersect Core ownership, so failure is safe. + pub async fn remove(&self, core_item_id: &str) -> Result<(), PileError> { + sqlx::query("DELETE FROM item_pile WHERE core_item_id = ?") + .bind(core_item_id) + .execute(&self.pool) + .await + .map_err(db)?; + Ok(()) + } } #[cfg(test)] diff --git a/openfut-utas-host/tests/economy_differential.rs b/openfut-utas-host/tests/economy_differential.rs index 838c46d..02a735e 100644 --- a/openfut-utas-host/tests/economy_differential.rs +++ b/openfut-utas-host/tests/economy_differential.rs @@ -409,6 +409,24 @@ fn pack_ids(pg: &Value) -> Vec { v } +/// Preserve every object key, array position, and JSON scalar kind while discarding +/// wire-insignificant values such as translated labels and live completion counts. +fn json_shape(value: &Value) -> Value { + match value { + Value::Null => json!("null"), + Value::Bool(_) => json!("bool"), + Value::Number(_) => json!("number"), + Value::String(_) => json!("string"), + Value::Array(values) => Value::Array(values.iter().map(json_shape).collect()), + Value::Object(values) => Value::Object( + values + .iter() + .map(|(key, value)| (key.clone(), json_shape(value))) + .collect(), + ), + } +} + /// Every op runs on a plain OS thread with NO ambient Tokio runtime (the blocking /// Core client + `reqwest::blocking` require this), exactly like the /// thread-per-connection server — the bridge takes its direct `block_on` path. @@ -1240,6 +1258,130 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) { assert_eq!(matrix.len(), 16, "all economy ops classified"); } +/// Compare the complete reversed `/sbs/*` response family against the Python oracle. +/// Listing labels and counters deliberately come from Core, so parity is defined as the +/// exact key/container/scalar-kind graph. Submission is the one semantic deviation: +/// Python acknowledges any body without consuming cards; Rust rejects an empty squad. +fn run_sbc_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) { + wait_ready(core_base); + let (server, _client, _sample_resource) = build_econ_server(core_base, dir); + let cases = [ + ("GET", "/ut/game/fifa17/sbs/sets", None), + ("GET", "/ut/game/fifa17/sbs/setId/1/challenges", None), + ("GET", "/ut/game/fifa17/sbs/setId/2/challenges", None), + ("GET", "/ut/game/fifa17/sbs/setId/999/challenges", None), + ("POST", "/ut/game/fifa17/sbs/sets/tag", Some(json!({}))), + ("POST", "/ut/game/fifa17/sbs/challenge/101", None), + ("GET", "/ut/game/fifa17/sbs/challenge/101/squad", None), + ( + "PUT", + "/ut/game/fifa17/sbs/challenge/101/squad", + Some(json!({ "squad": [] })), + ), + ]; + + for (method, path, body) in cases { + let oracle_result = oracle.req(method, path, body.clone(), None); + let bytes = body + .as_ref() + .map(|value| serde_json::to_vec(value).unwrap()) + .unwrap_or_default(); + let rust_result = rust(&server, method, path, &bytes, None); + assert_eq!( + rust_result.0, oracle_result.0, + "{method} {path} status parity" + ); + assert_eq!( + json_shape(&rust_result.1), + json_shape(&oracle_result.1), + "{method} {path} wire shape parity\nRust: {}\nOracle: {}", + rust_result.1, + oracle_result.1 + ); + match (method, path) { + ("GET", "/ut/game/fifa17/sbs/sets") => { + assert_eq!(rust_result.1["categories"][0]["categoryId"], 1); + assert_eq!(oracle_result.1["categories"][0]["categoryId"], 1); + let rust_set_ids: Vec = rust_result.1["categories"][0]["sets"] + .as_array() + .unwrap() + .iter() + .map(|set| set["setId"].as_i64().unwrap()) + .collect(); + let oracle_set_ids: Vec = oracle_result.1["categories"][0]["sets"] + .as_array() + .unwrap() + .iter() + .map(|set| set["setId"].as_i64().unwrap()) + .collect(); + assert_eq!(rust_set_ids, [1, 2]); + assert_eq!(oracle_set_ids, [1, 2]); + } + ("GET", "/ut/game/fifa17/sbs/setId/1/challenges") => { + for body in [&rust_result.1, &oracle_result.1] { + assert_eq!(body["challenges"][0]["challengeId"], 101); + assert_eq!(body["challenges"][0]["setId"], 1); + assert_eq!(body["challenges"][0]["categoryId"], 1); + } + } + ("GET", "/ut/game/fifa17/sbs/setId/2/challenges") => { + for body in [&rust_result.1, &oracle_result.1] { + assert_eq!(body["challenges"][0]["challengeId"], 201); + assert_eq!(body["challenges"][0]["setId"], 2); + assert_eq!(body["challenges"][0]["categoryId"], 1); + } + } + ("GET", "/ut/game/fifa17/sbs/setId/999/challenges") => { + assert_eq!(rust_result.1["challenges"], json!([])); + assert_eq!(oracle_result.1["challenges"], json!([])); + } + ("POST", "/ut/game/fifa17/sbs/challenge/101") => { + assert_eq!(rust_result.1["challengeId"], 101); + assert_eq!(oracle_result.1["challengeId"], 101); + } + (method, "/ut/game/fifa17/sbs/challenge/101/squad") => { + assert!(method == "GET" || method == "PUT"); + assert_eq!(rust_result.1["id"], 101); + assert_eq!(oracle_result.1["id"], 101); + } + _ => {} + } + } + + let submit = json!({ "squad": [] }); + let oracle_submit = oracle.req( + "PUT", + "/ut/game/fifa17/sbs/challenge/101", + Some(submit.clone()), + None, + ); + let rust_submit = rust( + &server, + "PUT", + "/ut/game/fifa17/sbs/challenge/101", + &serde_json::to_vec(&submit).unwrap(), + None, + ); + assert_eq!(oracle_submit.0, 200, "oracle preserves its no-op submit"); + assert_eq!( + json_shape(&oracle_submit.1), + json_shape(&json!({ + "challengeId": 0, + "setId": 0, + "credits": 0, + "preOrderPacks": 0, + "recoveredPacks": 0, + "grantedChallengeAwards": [], + "grantedSetAwards": [] + })), + "oracle submit response remains freeze-safe" + ); + assert_eq!( + rust_submit.0, 400, + "Rust must validate instead of copying the oracle's no-op acceptance" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn economy_differential_python_oracle() { let dir = std::env::temp_dir().join(format!( @@ -1281,3 +1423,35 @@ async fn economy_differential_python_oracle() { std::fs::remove_dir_all(&dir).ok(); outcome.expect("differential thread panicked"); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn sbc_differential_python_oracle() { + let dir = std::env::temp_dir().join(format!( + "openfut-sbc-diff-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let db_url = format!("sqlite://{}/sbc.db", dir.display()); + let (core_handle, core_base) = start_core_seeded(&db_url, true).await; + + let core_base_run = core_base.clone(); + let dir_run = dir.clone(); + let outcome = tokio::task::spawn_blocking(move || { + std::thread::spawn(move || { + let mut oracle = Oracle::spawn(&dir_run); + oracle.wait_ready(); + run_sbc_differential(&core_base_run, &oracle, &dir_run); + }) + .join() + }) + .await + .expect("join spawn_blocking"); + + core_handle.abort(); + std::fs::remove_dir_all(&dir).ok(); + outcome.expect("SBC differential thread panicked"); +}