From e8d1c1ddace19a26bb2e8bdc7ce65bac004818a8 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 18 Aug 2026 19:01:33 +0000 Subject: [PATCH] test(fifa17): harden SBC retail acceptance --- openfut-adapter-fifa17/src/fut/sbc.rs | 8 + openfut-utas-host/src/config.rs | 131 +++++++++- openfut-utas-host/src/lib.rs | 172 ++++++++++++- openfut-utas-host/src/market.rs | 3 +- .../tests/economy_differential.rs | 87 ++++++- .../tests/economy_integration.rs | 238 +++++++++++++++++- scripts/openfut-utas-observe.py | 14 +- scripts/test-utas-observe.py | 22 +- 8 files changed, 650 insertions(+), 25 deletions(-) diff --git a/openfut-adapter-fifa17/src/fut/sbc.rs b/openfut-adapter-fifa17/src/fut/sbc.rs index 1bde9fe..907ddc7 100644 --- a/openfut-adapter-fifa17/src/fut/sbc.rs +++ b/openfut-adapter-fifa17/src/fut/sbc.rs @@ -250,5 +250,13 @@ mod tests { parse_wire_item_ids(br#"{"challengeId":101}"#), Err(SbcWireError::MissingSquad) ); + assert!(matches!( + parse_wire_item_ids(br#"{"squad":"not-an-array"}"#), + Err(SbcWireError::MissingSquad) + )); + assert!(matches!( + parse_wire_item_ids(br#"{"squad":["#), + Err(SbcWireError::Json(_)) + )); } } diff --git a/openfut-utas-host/src/config.rs b/openfut-utas-host/src/config.rs index 421921e..184caf8 100644 --- a/openfut-utas-host/src/config.rs +++ b/openfut-utas-host/src/config.rs @@ -3,6 +3,18 @@ //! collide with the live oracle). `core_url` defaults to Bridge's convention. use std::env; +const SBC_FAULT_ACK: &str = "staging-only-sbc-receipt-loss"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SbcPostCommitFault { + #[default] + Off, + Drop, + Malformed, + Delay { + millis: u64, + }, +} #[derive(Debug, Clone)] pub struct HostConfig { @@ -44,6 +56,9 @@ pub struct HostConfig { /// `OPENFUT_ACCOUNT_PATH`, then existing `FUT_ACCOUNT_PATH`, then the /// identity store's parent directory + `active_account.json`. pub account_path: String, + /// Disabled by default. Non-off values require three explicit staging guards; + /// see [`parse_sbc_post_commit_fault`]. + pub sbc_post_commit_fault: SbcPostCommitFault, } #[derive(Debug)] @@ -76,9 +91,68 @@ fn required_i64_nonzero(key: &str) -> Result { Ok(val) } +fn parse_sbc_post_commit_fault( + raw: Option<&str>, + environment: Option<&str>, + ack: Option<&str>, + listen_addr: &str, +) -> Result { + let mode = match raw.filter(|value| !value.is_empty()).unwrap_or("off") { + "off" => return Ok(SbcPostCommitFault::Off), + "drop" => SbcPostCommitFault::Drop, + "malformed" => SbcPostCommitFault::Malformed, + value if value.starts_with("delay:") => { + let millis = value["delay:".len()..].parse::().map_err(|_| { + ConfigError( + "OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT delay must be delay:" + .into(), + ) + })?; + if !(1..=30_000).contains(&millis) { + return Err(ConfigError( + "OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT delay must be 1..=30000 ms".into(), + )); + } + SbcPostCommitFault::Delay { millis } + } + value => { + return Err(ConfigError(format!( + "OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT must be off, drop, malformed, or delay:; got {value:?}" + ))); + } + }; + if environment != Some("staging") { + return Err(ConfigError( + "SBC post-commit faults require OPENFUT_ENVIRONMENT=staging".into(), + )); + } + if ack != Some(SBC_FAULT_ACK) { + return Err(ConfigError(format!( + "SBC post-commit faults require OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT_ACK={SBC_FAULT_ACK}" + ))); + } + if listen_addr.rsplit_once(':').map(|(_, port)| port) == Some("8099") { + return Err(ConfigError( + "SBC post-commit faults refuse the production UTAS port 8099".into(), + )); + } + Ok(mode) +} + impl HostConfig { pub fn from_env() -> Result { let identity_store_path = required("OPENFUT_IDENTITY_STORE")?; + let listen_addr = required("OPENFUT_UTAS_HOST_ADDR")?; + let sbc_post_commit_fault = parse_sbc_post_commit_fault( + env::var("OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT") + .ok() + .as_deref(), + env::var("OPENFUT_ENVIRONMENT").ok().as_deref(), + env::var("OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT_ACK") + .ok() + .as_deref(), + &listen_addr, + )?; let clientdata_path = env::var("OPENFUT_CLIENTDATA_DB") .ok() .filter(|v| !v.is_empty()) @@ -89,7 +163,7 @@ impl HostConfig { .or_else(|| env::var("FUT_ACCOUNT_PATH").ok().filter(|v| !v.is_empty())) .unwrap_or_else(|| default_account_path(&identity_store_path)); Ok(HostConfig { - listen_addr: required("OPENFUT_UTAS_HOST_ADDR")?, + listen_addr, python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?, core_url: env::var("OPENFUT_CORE_URL") .unwrap_or_else(|_| "http://127.0.0.1:8080".into()), @@ -102,6 +176,7 @@ impl HostConfig { identity_store_path, clientdata_path, account_path, + sbc_post_commit_fault, }) } } @@ -125,3 +200,57 @@ fn default_account_path(identity_store_path: &str) -> String { .to_string_lossy() .into_owned() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sbc_post_commit_faults_require_all_staging_guards() { + assert_eq!( + parse_sbc_post_commit_fault(None, None, None, "0.0.0.0:8099").unwrap(), + SbcPostCommitFault::Off + ); + assert!(parse_sbc_post_commit_fault( + Some("drop"), + None, + Some(SBC_FAULT_ACK), + "127.0.0.1:18199" + ) + .is_err()); + assert!(parse_sbc_post_commit_fault( + Some("drop"), + Some("staging"), + None, + "127.0.0.1:18199" + ) + .is_err()); + assert!(parse_sbc_post_commit_fault( + Some("drop"), + Some("staging"), + Some(SBC_FAULT_ACK), + "0.0.0.0:8099" + ) + .is_err()); + assert_eq!( + parse_sbc_post_commit_fault( + Some("drop"), + Some("staging"), + Some(SBC_FAULT_ACK), + "0.0.0.0:18199" + ) + .unwrap(), + SbcPostCommitFault::Drop + ); + assert_eq!( + parse_sbc_post_commit_fault( + Some("delay:25"), + Some("staging"), + Some(SBC_FAULT_ACK), + "0.0.0.0:18199" + ) + .unwrap(), + SbcPostCommitFault::Delay { millis: 25 } + ); + } +} diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index f22d911..956a011 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -83,7 +83,7 @@ use serde_json::{json, Value}; use account_store::{AccountStore, RenameOutcome}; use clientdata_store::ClientDataStore; -use config::HostConfig; +use config::{HostConfig, SbcPostCommitFault}; // ───────────────────────────── Route classification ───────────────────────── @@ -1792,6 +1792,7 @@ fn error_response(status: u16, code: &str) -> WireResponse { status, headers: vec![("Content-Type".to_string(), "application/json".to_string())], body: format!("{{\"error\":\"{code}\"}}").into_bytes(), + transport: ResponseTransport::Normal, } } @@ -2236,12 +2237,33 @@ pub fn handle_match_end(econ: &dyn CoreEconomy, body: &[u8]) -> WireResponse { // ───────────────────────────── HTTP wire types ────────────────────────────── -/// A response ready to write: status, headers, body. +/// A response ready to write: status, headers, body, and an internal +/// staging-only transport directive. #[derive(Debug, Clone)] pub struct WireResponse { pub status: u16, pub headers: Vec<(String, String)>, pub body: Vec, + transport: ResponseTransport, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ResponseTransport { + Normal, + Drop, + Malformed, + Delay { millis: u64 }, +} + +impl From for ResponseTransport { + fn from(value: SbcPostCommitFault) -> Self { + match value { + SbcPostCommitFault::Off => Self::Normal, + SbcPostCommitFault::Drop => Self::Drop, + SbcPostCommitFault::Malformed => Self::Malformed, + SbcPostCommitFault::Delay { millis } => Self::Delay { millis }, + } + } } fn json_response(body: &Value) -> WireResponse { @@ -2250,6 +2272,7 @@ fn json_response(body: &Value) -> WireResponse { status: 200, headers: vec![("Content-Type".to_string(), "application/json".to_string())], body: bytes, + transport: ResponseTransport::Normal, } } @@ -2323,6 +2346,7 @@ impl PassClient { status, headers: out, body: bytes, + transport: ResponseTransport::Normal, }) } } @@ -2445,6 +2469,8 @@ pub struct Server { /// Core supplies the database transaction; this gate closes the cross-store race /// within one host process. economy_gate: Arc>, + /// Staging-only simulation of losing the successful SBC submit receipt. + sbc_post_commit_fault: SbcPostCommitFault, } impl Server { @@ -2468,6 +2494,7 @@ impl Server { economy: None, clientdata: Arc::new(ClientDataStore::open(ephemeral_clientdata_path())), economy_gate: Arc::new(Mutex::new(())), + sbc_post_commit_fault: SbcPostCommitFault::Off, } } @@ -2531,6 +2558,10 @@ impl Server { let clientdata = Arc::new(ClientDataStore::open(cfg.clientdata_path.clone())); let account = Arc::new(AccountStore::open(cfg.account_path.clone())); + eprintln!( + "utas-host sbc_post_commit_fault={:?}", + cfg.sbc_post_commit_fault + ); Ok(Server::new( core, entities, @@ -2540,7 +2571,8 @@ impl Server { ) .with_economy(economy) .with_clientdata(clientdata) - .with_account(account)) + .with_account(account) + .with_sbc_post_commit_fault(cfg.sbc_post_commit_fault)) } /// Assemble the shared squad dependencies (Core access + the one production @@ -2575,6 +2607,11 @@ impl Server { self } + fn with_sbc_post_commit_fault(mut self, fault: SbcPostCommitFault) -> Self { + self.sbc_post_commit_fault = fault; + self + } + fn sbc_views(&self) -> Result, CoreError> { let definitions = self.core.list_sbcs()?; let completions = self.core.sbc_completion_counts()?; @@ -2819,7 +2856,7 @@ impl Server { Ok(packs) => packs.len() as i64, Err(error) => return core_sbc_error_response(error), }; - json_status( + let mut response = json_status( 200, &fifa17_sbc::submit_body( challenge_id, @@ -2827,7 +2864,9 @@ impl Server { credits, unopened_packs, ), - ) + ); + response.transport = self.sbc_post_commit_fault.into(); + response } _ => json_status(500, &json!({ "error": "invalid SBC route dispatch" })), } @@ -3183,6 +3222,7 @@ impl Server { "application/json".to_string(), )], body: br#"{"error":"upstream unavailable"}"#.to_vec(), + transport: ResponseTransport::Normal, } } }; @@ -3603,6 +3643,7 @@ impl Server { status: 204, headers: Vec::new(), body: Vec::new(), + transport: ResponseTransport::Normal, }, "captcha" => json_status( 200, @@ -3770,10 +3811,11 @@ impl Server { &req.body, peer_ip.as_deref(), ); - if write_response(&mut writer, &resp).is_err() { - return; - } - if req.close { + let close = match write_response(&mut writer, &resp) { + Ok(close) => close, + Err(_) => return, + }; + if close || req.close { return; } } @@ -3813,6 +3855,7 @@ fn json_status(status: u16, v: &Value) -> WireResponse { ("Content-Length".to_string(), body.len().to_string()), ], body, + transport: ResponseTransport::Normal, } } @@ -3981,7 +4024,14 @@ fn reason(status: u16) -> &'static str { } } -fn write_response(w: &mut W, resp: &WireResponse) -> std::io::Result<()> { +fn write_response(w: &mut W, resp: &WireResponse) -> std::io::Result { + match resp.transport { + ResponseTransport::Drop => return Ok(true), + ResponseTransport::Delay { millis } => { + std::thread::sleep(std::time::Duration::from_millis(millis)); + } + ResponseTransport::Normal | ResponseTransport::Malformed => {} + } let mut head = format!("HTTP/1.1 {} {}\r\n", resp.status, reason(resp.status)); for (k, v) in &resp.headers { if is_hop_by_hop(k) { @@ -3992,8 +4042,14 @@ fn write_response(w: &mut W, resp: &WireResponse) -> std::io::Result<( head.push_str(&format!("Content-Length: {}\r\n", resp.body.len())); head.push_str("\r\n"); w.write_all(head.as_bytes())?; + if resp.transport == ResponseTransport::Malformed { + w.write_all(b"{")?; + w.flush()?; + return Ok(true); + } w.write_all(&resp.body)?; - w.flush() + w.flush()?; + Ok(false) } #[cfg(test)] @@ -5326,4 +5382,98 @@ mod tests { Some(1) ); } + + fn submit_sbc_with_fault(fault: SbcPostCommitFault) -> (WireResponse, Arc) { + let (server, core, _services, _resolver, wires) = sbc_test_server(); + let server = server.with_sbc_post_commit_fault(fault); + let save_body = serde_json::to_vec(&json!({ + "squad": [ + { "index": 0, "itemData": { "id": wires[0] } }, + { "index": 1, "itemData": { "id": wires[1] } } + ] + })) + .unwrap(); + assert_eq!( + server + .handle( + "PUT", + "/ut/game/fifa17/sbs/challenge/101/squad", + &[], + &save_body, + ) + .status, + 200 + ); + let response = server.handle("PUT", "/ut/game/fifa17/sbs/challenge/101", &[], br#"{}"#); + (response, core) + } + + #[test] + fn sbc_post_commit_fault_modes_lose_only_the_receipt() { + let (normal_response, normal_core) = submit_sbc_with_fault(SbcPostCommitFault::Off); + let mut normal = Vec::new(); + assert!(!write_response(&mut normal, &normal_response).unwrap()); + assert!(normal.ends_with(&normal_response.body)); + assert_eq!( + normal_core + .completions + .lock() + .unwrap() + .get("sbc_bronze_upgrade") + .copied(), + Some(1) + ); + + let (drop_response, drop_core) = submit_sbc_with_fault(SbcPostCommitFault::Drop); + assert_eq!(drop_response.status, 200); + assert_eq!( + drop_core + .completions + .lock() + .unwrap() + .get("sbc_bronze_upgrade") + .copied(), + Some(1), + "Core commit precedes the simulated connection drop" + ); + let mut dropped = Vec::new(); + assert!(write_response(&mut dropped, &drop_response).unwrap()); + assert!(dropped.is_empty(), "drop mode writes no receipt bytes"); + + let (malformed_response, malformed_core) = + submit_sbc_with_fault(SbcPostCommitFault::Malformed); + assert_eq!( + malformed_core + .completions + .lock() + .unwrap() + .get("sbc_bronze_upgrade") + .copied(), + Some(1) + ); + let mut malformed = Vec::new(); + assert!(write_response(&mut malformed, &malformed_response).unwrap()); + assert!(malformed.ends_with(b"{")); + assert!( + malformed_response.body.len() > 1, + "advertised body is deliberately truncated" + ); + + let (delayed_response, delayed_core) = + submit_sbc_with_fault(SbcPostCommitFault::Delay { millis: 20 }); + let started = Instant::now(); + let mut delayed = Vec::new(); + assert!(!write_response(&mut delayed, &delayed_response).unwrap()); + assert!(started.elapsed() >= std::time::Duration::from_millis(20)); + assert!(delayed.ends_with(&delayed_response.body)); + assert_eq!( + delayed_core + .completions + .lock() + .unwrap() + .get("sbc_bronze_upgrade") + .copied(), + Some(1) + ); + } } diff --git a/openfut-utas-host/src/market.rs b/openfut-utas-host/src/market.rs index 3912bbf..76e32a6 100644 --- a/openfut-utas-host/src/market.rs +++ b/openfut-utas-host/src/market.rs @@ -36,7 +36,7 @@ use crate::economy_store::OwnedItemLookup; use crate::market_store::{now_secs, Listing, MarketError, MarketStore}; use crate::pile_store::PileStore; use crate::sold_experiment::{CountMode, SoldExperiment}; -use crate::{CoreEconomy, CoreError, WireResponse}; +use crate::{CoreEconomy, CoreError, ResponseTransport, WireResponse}; /// FIFA trade-id numbering base (mirrors the oracle's `_TRADE_ID_BASE`). const TRADE_ID_BASE: i64 = 900_000_000; @@ -47,6 +47,7 @@ fn json_body(status: u16, body: &Value) -> WireResponse { status, headers: vec![("Content-Type".to_string(), "application/json".to_string())], body: bytes, + transport: ResponseTransport::Normal, } } diff --git a/openfut-utas-host/tests/economy_differential.rs b/openfut-utas-host/tests/economy_differential.rs index 02a735e..dbaeca6 100644 --- a/openfut-utas-host/tests/economy_differential.rs +++ b/openfut-utas-host/tests/economy_differential.rs @@ -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, 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::>() + }); + 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)] diff --git a/openfut-utas-host/tests/economy_integration.rs b/openfut-utas-host/tests/economy_integration.rs index 73080e2..1eee5ef 100644 --- a/openfut-utas-host/tests/economy_integration.rs +++ b/openfut-utas-host/tests/economy_integration.rs @@ -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 = 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 = 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::>() + }); + 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::>() + }); + 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)> { "/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(), + ), ] } diff --git a/scripts/openfut-utas-observe.py b/scripts/openfut-utas-observe.py index 28bb35f..bdd92c6 100755 --- a/scripts/openfut-utas-observe.py +++ b/scripts/openfut-utas-observe.py @@ -278,7 +278,7 @@ def cmd_parse(args): os.makedirs(out_dir, exist_ok=True) out_path = os.path.join(out_dir, "transactions.jsonl") - total, skipped = 0, 0 + total, skipped, filtered = 0, 0, 0 with open(out_path, "w") as out: for conn_id in sorted(conns): c = conns[conn_id] @@ -298,6 +298,9 @@ def cmd_parse(args): m = re.match(r"(\S+)\s+(\S+)\s+(HTTP/\d\.\d)", rl) method, target, ver = (m.group(1), m.group(2), m.group(3)) if m else ("?", rl, "?") path, _, query = target.partition("?") + if args.path_prefix and not path.startswith(args.path_prefix): + filtered += 1 + continue st = re.match(r"HTTP/\d\.\d\s+(\d+)", sl) req_t, req_unix = time_at(c["marks"]["c2s"], rend - 1) res_t, res_unix = time_at(c["marks"]["s2c"], max(send - 1, 0)) @@ -342,9 +345,14 @@ def cmd_parse(args): total += 1 os.chmod(out_path, 0o644) + detail = [] + if skipped: + detail.append("%d unpaired messages reported above" % skipped) + if filtered: + detail.append("%d transactions excluded by path prefix" % filtered) print("wrote %s (%d transactions across %d connections%s)" % (out_path, total, len(conns), - "; %d unpaired messages reported above" % skipped if skipped else "")) + "; " + "; ".join(detail) if detail else "")) return 0 @@ -587,6 +595,8 @@ def main(): # fixture for the whole response. p.add_argument("--max-body", type=int, default=0, help="bytes; 0 = keep every body in full") + p.add_argument("--path-prefix", default="", + help="emit only transactions whose request path starts with this prefix") p.set_defaults(fn=cmd_parse) s = sub.add_parser("snapshot") diff --git a/scripts/test-utas-observe.py b/scripts/test-utas-observe.py index 943e893..23bec8a 100755 --- a/scripts/test-utas-observe.py +++ b/scripts/test-utas-observe.py @@ -123,8 +123,8 @@ def main(): body = json.dumps({"squad": [1, 2, 3], "note": "x" * 300}).encode() reqs = [ - b"GET /ut/game/fifa17/userMassInfo HTTP/1.1\r\nHost: t\r\n\r\n", - b"POST /ut/game/fifa17/purchased/items HTTP/1.1\r\nHost: t\r\n" + b"GET /ut/game/fifa17/sbs/sets HTTP/1.1\r\nHost: t\r\n\r\n", + b"POST /ut/game/fifa17/sbs/challenge/101/squad HTTP/1.1\r\nHost: t\r\n" b"Content-Type: application/json\r\nContent-Length: %d\r\n\r\n" % len(body) + body, b"GET /chunked?deviceId=DEADBEEFCAFE&keep=yes HTTP/1.1\r\nHost: t\r\n\r\n", b"GET /ut/game/fifa17/hub HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n", @@ -180,8 +180,8 @@ def main(): if len(txs) == 4: check("methods and paths in order", [(t["request"]["method"], t["request"]["path"]) for t in txs] == - [("GET", "/ut/game/fifa17/userMassInfo"), - ("POST", "/ut/game/fifa17/purchased/items"), + [("GET", "/ut/game/fifa17/sbs/sets"), + ("POST", "/ut/game/fifa17/sbs/challenge/101/squad"), ("GET", "/chunked"), ("GET", "/ut/game/fifa17/hub")]) check("request body preserved byte-for-byte", @@ -210,6 +210,20 @@ def main(): qtx is not None and "keep=yes" in qtx["request"]["query"], qtx["request"]["query"] if qtx else "") + print("== path-scoped fixture ==") + r = subprocess.run( + [sys.executable, TOOL, "parse", "--session", SESSION, + "--path-prefix", "/ut/game/fifa17/sbs/"], + capture_output=True, text=True) + print(" " + r.stdout.strip().replace("\n", "\n ")) + filtered = [json.loads(l) for l in + open(os.path.join(SESSION, "sanitized", "transactions.jsonl"))] + check("SBC prefix emits only the two SBC transactions", len(filtered) == 2, + "got %d" % len(filtered)) + check("SBC save body remains byte-exact after scoped parse", + len(filtered) == 2 and + base64.b64decode(filtered[1]["request"]["body_b64"]) == body) + srv.shutdown() print() print("all checks passed" if not fails else "%d FAILED: %s" % (len(fails), fails))