feat(fifa17): route SBCs through atomic Rust Core
This commit is contained in:
Generated
+1
@@ -3210,6 +3210,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tower 0.5.3",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule openfut-core updated: 637a21eac1...271c3639ed
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user