test(fifa17): harden SBC retail acceptance
This commit is contained in:
@@ -95,6 +95,7 @@
|
||||
//! killed on guard drop. Never touches the production Core DB/ports or `.105`.
|
||||
|
||||
use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog;
|
||||
use openfut_adapter_fifa17::fut::club_response::ItemIdentityResolver;
|
||||
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
||||
use openfut_adapter_fifa17::fut::non_economy::PERSONA_DISPLAY_NAME;
|
||||
use openfut_adapter_fifa17::fut::store_session::{SessionStore, StoreMode, SENTINEL_PACK_ID};
|
||||
@@ -310,8 +311,12 @@ fn core_post(http: &reqwest::blocking::Client, base: &str, path: &str, body: Val
|
||||
|
||||
/// Build a real `Server` with economy authority wired against the seeded Core.
|
||||
/// Returns the server, a direct Core client for balance/entitlement assertions,
|
||||
/// and a valid wire `resourceId` (20000) that reverse-maps to a real Core card.
|
||||
fn build_econ_server(base: &str, dir: &std::path::Path) -> (Server, HttpCoreClient, i64) {
|
||||
/// the resolver used to obtain stable wire instance ids, and a valid wire
|
||||
/// `resourceId` (20000) that reverse-maps to a real Core card.
|
||||
fn build_econ_server(
|
||||
base: &str,
|
||||
dir: &std::path::Path,
|
||||
) -> (Server, HttpCoreClient, Arc<Fifa17IdentityResolver>, i64) {
|
||||
let probe = HttpCoreClient::new(base, "fifa17");
|
||||
let owned = probe.all_owned().expect("core collection");
|
||||
assert!(!owned.is_empty(), "seed must grant a starter collection");
|
||||
@@ -374,7 +379,7 @@ fn build_econ_server(base: &str, dir: &std::path::Path) -> (Server, HttpCoreClie
|
||||
PERSONA_ID,
|
||||
)
|
||||
.with_economy(services);
|
||||
(server, probe, 20000)
|
||||
(server, probe, resolver, 20000)
|
||||
}
|
||||
|
||||
// ─────────────────────────── differential comparison ────────────────────────
|
||||
@@ -433,7 +438,7 @@ fn json_shape(value: &Value) -> Value {
|
||||
fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
wait_ready(core_base);
|
||||
let http = reqwest::blocking::Client::new();
|
||||
let (server, client, _sample_resource) = build_econ_server(core_base, dir);
|
||||
let (server, client, _resolver, _sample_resource) = build_econ_server(core_base, dir);
|
||||
|
||||
// ── Fixture alignment: both sides own exactly one pack-70 entitlement. ──
|
||||
// Oracle: fresh profile already owns pack 70. Core: grant the "70" entitlement
|
||||
@@ -1264,13 +1269,14 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
/// 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 (server, client, resolver, _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!({}))),
|
||||
("PUT", "/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),
|
||||
(
|
||||
@@ -1380,6 +1386,77 @@ fn run_sbc_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path)
|
||||
rust_submit.0, 400,
|
||||
"Rust must validate instead of copying the oracle's no-op acceptance"
|
||||
);
|
||||
|
||||
let owned = client.all_owned().expect("SBC differential inventory");
|
||||
let mut selected = Vec::new();
|
||||
for nation in ["Argentina", "Brazil"] {
|
||||
selected.push(
|
||||
owned
|
||||
.iter()
|
||||
.find(|item| item.rating >= 70 && item.nation == nation)
|
||||
.unwrap_or_else(|| panic!("missing {nation} SBC fixture")),
|
||||
);
|
||||
}
|
||||
for item in &owned {
|
||||
if selected.len() == 11 {
|
||||
break;
|
||||
}
|
||||
if item.rating >= 70
|
||||
&& !selected
|
||||
.iter()
|
||||
.any(|existing| existing.owned_card_id == item.owned_card_id)
|
||||
{
|
||||
selected.push(item);
|
||||
}
|
||||
}
|
||||
assert_eq!(selected.len(), 11);
|
||||
let successful = json!({
|
||||
"squad": selected
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| json!({
|
||||
"index": index,
|
||||
"itemData": {
|
||||
"id": i64::from(
|
||||
resolver.resolve(item).expect("SBC wire identity").item_id
|
||||
)
|
||||
}
|
||||
}))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let before_balance = client.balance().unwrap();
|
||||
let before_packs = client.entitlements().unwrap().len();
|
||||
let oracle_success = oracle.req(
|
||||
"POST",
|
||||
"/ut/game/fifa17/sbs/challenge/201",
|
||||
Some(successful.clone()),
|
||||
None,
|
||||
);
|
||||
let rust_success = rust(
|
||||
&server,
|
||||
"POST",
|
||||
"/ut/game/fifa17/sbs/challenge/201",
|
||||
&serde_json::to_vec(&successful).unwrap(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(oracle_success.0, 200);
|
||||
assert_eq!(rust_success.0, 200);
|
||||
assert_eq!(
|
||||
json_shape(&rust_success.1),
|
||||
json_shape(&oracle_success.1),
|
||||
"successful Rust submit preserves the oracle response class"
|
||||
);
|
||||
assert_eq!(rust_success.1["challengeId"], 201);
|
||||
assert_eq!(rust_success.1["setId"], 2);
|
||||
assert!(
|
||||
client.balance().unwrap() > before_balance,
|
||||
"Core applies challenge and achievement coin rewards"
|
||||
);
|
||||
assert_eq!(
|
||||
client.entitlements().unwrap().len(),
|
||||
before_packs + 1,
|
||||
"Core grants one Hybrid Nations pack"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! touches the production Core DB, production ports/containers, or `.105`.
|
||||
|
||||
use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog;
|
||||
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
||||
use openfut_adapter_fifa17::fut::entities::{Fifa17Entities, ReverseEntityResolver};
|
||||
use openfut_adapter_fifa17::fut::store_session::StoreMode;
|
||||
use openfut_identity::JsonIdentityStore;
|
||||
use openfut_utas_host::async_bridge::AsyncBridge;
|
||||
@@ -769,6 +769,7 @@ fn from_config(base: &str, dir: &std::path::Path) -> openfut_utas_host::config::
|
||||
.join("active_account.json")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
sbc_post_commit_fault: openfut_utas_host::config::SbcPostCommitFault::Off,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -908,6 +909,212 @@ async fn from_config_constructs_and_serves_economy() {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn sbc_survives_complete_core_and_host_restart() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"openfut-sbc-restart-{}-{}",
|
||||
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 (h1, base1) = start_core_seeded(&db_url, true).await;
|
||||
let first_base = base1.clone();
|
||||
let first_dir = dir.clone();
|
||||
let (mut cfg, selected_core_ids, selected_wire_ids) = tokio::task::spawn_blocking(move || {
|
||||
let cfg = from_config(&first_base, &first_dir);
|
||||
let server = Server::from_config(&cfg).expect("first complete host");
|
||||
let club = server.handle("GET", "/ut/game/fifa17/club?count=200", &[], b"");
|
||||
assert_eq!(club.status, 200);
|
||||
let club: Value = serde_json::from_slice(&club.body).unwrap();
|
||||
let items = club["itemData"].as_array().expect("club itemData");
|
||||
let entities =
|
||||
Fifa17Entities::from_tables_dir(std::path::Path::new(&cfg.tables_dir)).unwrap();
|
||||
let argentina = i64::from(entities.nation_id("Argentina").unwrap());
|
||||
let brazil = i64::from(entities.nation_id("Brazil").unwrap());
|
||||
let mut selected = Vec::new();
|
||||
for nation in [argentina, brazil] {
|
||||
selected.push(
|
||||
items
|
||||
.iter()
|
||||
.find(|item| {
|
||||
item["rating"].as_i64().unwrap_or_default() >= 70
|
||||
&& item["nation"].as_i64() == Some(nation)
|
||||
})
|
||||
.expect("required hybrid nation")
|
||||
.clone(),
|
||||
);
|
||||
}
|
||||
for item in items {
|
||||
if selected.len() == 11 {
|
||||
break;
|
||||
}
|
||||
if item["rating"].as_i64().unwrap_or_default() >= 70
|
||||
&& !selected.iter().any(|existing| existing["id"] == item["id"])
|
||||
{
|
||||
selected.push(item.clone());
|
||||
}
|
||||
}
|
||||
assert_eq!(selected.len(), 11, "deterministic Hybrid Nations squad");
|
||||
let selected_wire_ids: Vec<i64> = selected
|
||||
.iter()
|
||||
.map(|item| item["id"].as_i64().expect("wire id"))
|
||||
.collect();
|
||||
let catalog = Fifa17CardCatalog::from_file(std::path::Path::new(&cfg.catalog_path))
|
||||
.expect("catalog reload");
|
||||
let store = JsonIdentityStore::open(&cfg.identity_store_path).expect("identity reload");
|
||||
let resolver = Fifa17IdentityResolver::new(catalog, Arc::new(store));
|
||||
let selected_core_ids: Vec<String> = selected_wire_ids
|
||||
.iter()
|
||||
.map(|wire| {
|
||||
resolver
|
||||
.owned_id_for_wire(*wire)
|
||||
.expect("wire mapping persisted")
|
||||
})
|
||||
.collect();
|
||||
let squad = json!({
|
||||
"squad": selected_wire_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, id)| json!({
|
||||
"index": index,
|
||||
"itemData": { "id": id },
|
||||
"kitNumber": 0
|
||||
}))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let squad_bytes = serde_json::to_vec(&squad).unwrap();
|
||||
assert_eq!(
|
||||
server
|
||||
.handle(
|
||||
"PUT",
|
||||
"/ut/game/fifa17/sbs/challenge/201/squad",
|
||||
&[],
|
||||
&squad_bytes,
|
||||
)
|
||||
.status,
|
||||
200
|
||||
);
|
||||
let submitted = server.handle("PUT", "/ut/game/fifa17/sbs/challenge/201", &[], br#"{}"#);
|
||||
assert_eq!(submitted.status, 200);
|
||||
let submitted: Value = serde_json::from_slice(&submitted.body).unwrap();
|
||||
assert_eq!(submitted["challengeId"], 201);
|
||||
assert_eq!(submitted["credits"], 102_750);
|
||||
assert_eq!(submitted["recoveredPacks"], 1);
|
||||
(cfg, selected_core_ids, selected_wire_ids)
|
||||
})
|
||||
.await
|
||||
.expect("initial complete-host phase");
|
||||
|
||||
h1.abort();
|
||||
let _ = h1.await;
|
||||
let (h2, base2) = start_core_seeded(&db_url, false).await;
|
||||
cfg.core_url = base2.clone();
|
||||
let selected_core_ids_check = selected_core_ids.clone();
|
||||
let selected_wire_ids_check = selected_wire_ids.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let server = Server::from_config(&cfg).expect("restarted complete host");
|
||||
let client = HttpCoreClient::new(&base2, "fifa17");
|
||||
assert_eq!(client.balance().unwrap(), 102_750);
|
||||
assert_eq!(client.entitlements().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
client
|
||||
.sbc_completion_counts()
|
||||
.unwrap()
|
||||
.get("sbc_hybrid_nations")
|
||||
.copied(),
|
||||
Some(1)
|
||||
);
|
||||
let owned = client.all_owned().unwrap();
|
||||
assert!(selected_core_ids_check
|
||||
.iter()
|
||||
.all(|id| { owned.iter().all(|item| item.owned_card_id != *id) }));
|
||||
|
||||
let club = server.handle("GET", "/ut/game/fifa17/club?count=200", &[], b"");
|
||||
let club: Value = serde_json::from_slice(&club.body).unwrap();
|
||||
assert!(selected_wire_ids_check.iter().all(|id| {
|
||||
club["itemData"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|item| item["id"].as_i64() != Some(*id))
|
||||
}));
|
||||
let saved = server.handle("GET", "/ut/game/fifa17/sbs/challenge/201/squad", &[], b"");
|
||||
let saved: Value = serde_json::from_slice(&saved.body).unwrap();
|
||||
assert!(saved["squad"].as_array().unwrap().is_empty());
|
||||
let purchased = server.handle("GET", "/ut/v2/game/fifa17/purchased/items", &[], b"");
|
||||
let purchased: Value = serde_json::from_slice(&purchased.body).unwrap();
|
||||
assert!(purchased["itemData"].as_array().unwrap().is_empty());
|
||||
for path in ["/ut/game/fifa17/tradePile", "/ut/game/fifa17/watchList"] {
|
||||
let pile = server.handle("GET", path, &[], b"");
|
||||
let pile: Value = serde_json::from_slice(&pile.body).unwrap();
|
||||
assert!(pile["auctionInfo"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|auction| {
|
||||
selected_wire_ids_check
|
||||
.iter()
|
||||
.all(|id| auction["itemData"]["id"].as_i64() != Some(*id))
|
||||
}));
|
||||
}
|
||||
let active = server.handle("GET", "/ut/game/fifa17/squad/active", &[], b"");
|
||||
let active: Value = serde_json::from_slice(&active.body).unwrap();
|
||||
assert!(selected_wire_ids_check.iter().all(|id| {
|
||||
active["players"].as_array().is_none_or(|players| {
|
||||
players
|
||||
.iter()
|
||||
.all(|slot| slot["itemData"]["id"].as_i64() != Some(*id))
|
||||
})
|
||||
}));
|
||||
|
||||
let replay_body = json!({
|
||||
"squad": selected_wire_ids_check
|
||||
.iter()
|
||||
.map(|id| json!({ "itemData": { "id": id } }))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
assert_eq!(
|
||||
server
|
||||
.handle(
|
||||
"PUT",
|
||||
"/ut/game/fifa17/sbs/challenge/201",
|
||||
&[],
|
||||
&serde_json::to_vec(&replay_body).unwrap(),
|
||||
)
|
||||
.status,
|
||||
404,
|
||||
"host rejects consumed wire ids before a duplicate effect"
|
||||
);
|
||||
assert!(matches!(
|
||||
client.submit_sbc("sbc_hybrid_nations", &selected_core_ids_check),
|
||||
Err(openfut_utas_host::CoreError::Status(409))
|
||||
));
|
||||
assert_eq!(client.balance().unwrap(), 102_750);
|
||||
assert_eq!(client.entitlements().unwrap().len(), 1);
|
||||
|
||||
let catalog = Fifa17CardCatalog::from_file(std::path::Path::new(&cfg.catalog_path))
|
||||
.expect("restart catalog");
|
||||
let store = JsonIdentityStore::open(&cfg.identity_store_path).expect("restart identity");
|
||||
let resolver = Fifa17IdentityResolver::new(catalog, Arc::new(store));
|
||||
for (wire, core_id) in selected_wire_ids_check.iter().zip(&selected_core_ids_check) {
|
||||
assert_eq!(
|
||||
resolver.owned_id_for_wire(*wire).as_deref(),
|
||||
Some(core_id.as_str())
|
||||
);
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("restart verification");
|
||||
h2.abort();
|
||||
let _ = h2.await;
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// ─────────────── Post-barrier authority proofs (NEVER BOTH / no fallback / ────
|
||||
// stale reader), through the REAL handle_with_ip dispatch ──────
|
||||
|
||||
@@ -1040,6 +1247,35 @@ fn pure_economy_routes() -> Vec<(&'static str, String, Vec<u8>)> {
|
||||
"/ut/game/fifa17/tradePile/counts".into(),
|
||||
b"".to_vec(),
|
||||
),
|
||||
// ── FIFA 17 SBC family. Reads, tag acknowledgement, durable squad
|
||||
// saves, challenge start, and submission are all Rust-owned. ──
|
||||
("GET", "/ut/game/fifa17/sbs/sets".into(), b"".to_vec()),
|
||||
(
|
||||
"GET",
|
||||
"/ut/game/fifa17/sbs/setId/1/challenges".into(),
|
||||
b"".to_vec(),
|
||||
),
|
||||
(
|
||||
"GET",
|
||||
"/ut/game/fifa17/sbs/challenge/101/squad".into(),
|
||||
b"".to_vec(),
|
||||
),
|
||||
("PUT", "/ut/game/fifa17/sbs/sets/tag".into(), b"{}".to_vec()),
|
||||
(
|
||||
"PUT",
|
||||
"/ut/game/fifa17/sbs/challenge/101/squad".into(),
|
||||
br#"{"squad":[]}"#.to_vec(),
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"/ut/game/fifa17/sbs/challenge/101".into(),
|
||||
b"".to_vec(),
|
||||
),
|
||||
(
|
||||
"PUT",
|
||||
"/ut/game/fifa17/sbs/challenge/101".into(),
|
||||
br#"{"squad":[]}"#.to_vec(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user