feat(fifa17): route SBCs through atomic Rust Core

This commit is contained in:
funman300
2026-08-18 18:26:35 +00:00
parent bf6db98f0d
commit f9740f640d
8 changed files with 1345 additions and 7 deletions
Generated
+1
View File
@@ -3210,6 +3210,7 @@ dependencies = [
"serde",
"serde_json",
"sqlx",
"tempfile",
"thiserror 1.0.69",
"tokio",
"tower 0.5.3",
+1
View File
@@ -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;
+254
View File
@@ -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<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.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| {
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<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.
///
/// 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<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 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)
);
}
}
+892 -6
View File
@@ -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/<id>/challenges` — challenges in one set.
SbcChallenges,
/// `GET/PUT …/sbs/challenge/<id>/squad` — durable working squad.
SbcChallengeSquad,
/// `POST/PUT …/sbs/challenge/<id>` — start or atomic submission.
SbcChallenge,
}
/// `item/<digits>` — 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<i64> {
bounded_numeric_segment(tail, "sbs/challenge/", "")
.or_else(|| bounded_numeric_segment(tail, "sbs/challenge/", "/squad"))?
.parse()
.ok()
}
fn sbc_set_id(tail: &str) -> Option<i64> {
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<EconomyRoute> {
// 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<String>,
}
pub trait CoreAccess: Send + Sync {
/// Query the owned inventory with semantic `/collection` query params.
fn query_owned(&self, params: &[(&str, String)]) -> Result<CorePage, CoreError>;
@@ -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<CoreReplaceResult, CoreError>;
fn list_sbcs(&self) -> Result<Vec<CoreSbcDefinition>, CoreError> {
Err(CoreError::Parse(
"Core SBC access is not implemented".into(),
))
}
fn sbc_completion_counts(&self) -> Result<std::collections::HashMap<String, i64>, CoreError> {
Err(CoreError::Parse(
"Core SBC access is not implemented".into(),
))
}
fn load_sbc_squad(&self, _sbc_id: &str) -> Result<Vec<String>, CoreError> {
Err(CoreError::Parse(
"Core SBC access is not implemented".into(),
))
}
fn save_sbc_squad(
&self,
_sbc_id: &str,
_owned_card_ids: &[String],
) -> Result<Vec<String>, CoreError> {
Err(CoreError::Parse(
"Core SBC access is not implemented".into(),
))
}
fn submit_sbc(
&self,
_sbc_id: &str,
_owned_card_ids: &[String],
) -> Result<CoreSbcResult, CoreError> {
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<Vec<CoreSbcDefinition>, 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<std::collections::HashMap<String, i64>, 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<Vec<String>, 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<Vec<String>, 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<CoreSbcResult, CoreError> {
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<Vec<String>, 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<ClientDataStore>,
/// 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<Mutex<()>>,
}
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<Vec<fifa17_sbc::ChallengeView>, 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<Vec<String>, WireResponse> {
let owned: std::collections::HashSet<String> = 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<Vec<i64>, WireResponse> {
let owned: std::collections::HashMap<String, CoreOwnedItem> = 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<WireResponse> {
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: Write>(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<Vec<CoreOwnedItem>>,
saved: Mutex<std::collections::HashMap<String, Vec<String>>>,
completions: Mutex<std::collections::HashMap<String, i64>>,
}
impl CoreAccess for FakeSbcCore {
fn query_owned(&self, _params: &[(&str, String)]) -> Result<CorePage, CoreError> {
let items = self.owned.lock().unwrap().clone();
Ok(CorePage {
total: items.len() as i64,
items,
})
}
fn read_squad_ext(&self, _namespace: &str) -> Result<CoreSquadRead, CoreError> {
Err(CoreError::Status(501))
}
fn replace_squad(
&self,
_request: &CoreReplaceRequest,
) -> Result<CoreReplaceResult, CoreError> {
Err(CoreError::Status(501))
}
fn list_sbcs(&self) -> Result<Vec<CoreSbcDefinition>, 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<std::collections::HashMap<String, i64>, CoreError> {
Ok(self.completions.lock().unwrap().clone())
}
fn load_sbc_squad(&self, sbc_id: &str) -> Result<Vec<String>, 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<Vec<String>, 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<CoreSbcResult, CoreError> {
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<FakeSbcCore>,
Arc<EconomyServices>,
Arc<Fifa17IdentityResolver>,
[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)
);
}
}
+12
View File
@@ -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<bool, MarketError> {
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)]
+10
View File
@@ -159,6 +159,16 @@ impl PileStore {
.map(|r| r.get::<String, _>("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)]
@@ -409,6 +409,24 @@ fn pack_ids(pg: &Value) -> Vec<u64> {
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<i64> = rust_result.1["categories"][0]["sets"]
.as_array()
.unwrap()
.iter()
.map(|set| set["setId"].as_i64().unwrap())
.collect();
let oracle_set_ids: Vec<i64> = 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");
}