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