Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8d1c1ddac | |||
| f9740f640d |
Generated
+1
@@ -3210,6 +3210,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower 0.5.3",
|
"tower 0.5.3",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub mod item;
|
|||||||
pub mod non_economy;
|
pub mod non_economy;
|
||||||
pub mod owned_query;
|
pub mod owned_query;
|
||||||
pub mod pack_content;
|
pub mod pack_content;
|
||||||
|
pub mod sbc;
|
||||||
pub mod squad;
|
pub mod squad;
|
||||||
pub mod squad_ext;
|
pub mod squad_ext;
|
||||||
pub mod squad_projection;
|
pub mod squad_projection;
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
//! 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)
|
||||||
|
);
|
||||||
|
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(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
Submodule openfut-core updated: 637a21eac1...271c3639ed
@@ -3,6 +3,18 @@
|
|||||||
//! collide with the live oracle). `core_url` defaults to Bridge's convention.
|
//! collide with the live oracle). `core_url` defaults to Bridge's convention.
|
||||||
|
|
||||||
use std::env;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct HostConfig {
|
pub struct HostConfig {
|
||||||
@@ -44,6 +56,9 @@ pub struct HostConfig {
|
|||||||
/// `OPENFUT_ACCOUNT_PATH`, then existing `FUT_ACCOUNT_PATH`, then the
|
/// `OPENFUT_ACCOUNT_PATH`, then existing `FUT_ACCOUNT_PATH`, then the
|
||||||
/// identity store's parent directory + `active_account.json`.
|
/// identity store's parent directory + `active_account.json`.
|
||||||
pub account_path: String,
|
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)]
|
#[derive(Debug)]
|
||||||
@@ -76,9 +91,68 @@ fn required_i64_nonzero(key: &str) -> Result<i64, ConfigError> {
|
|||||||
Ok(val)
|
Ok(val)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_sbc_post_commit_fault(
|
||||||
|
raw: Option<&str>,
|
||||||
|
environment: Option<&str>,
|
||||||
|
ack: Option<&str>,
|
||||||
|
listen_addr: &str,
|
||||||
|
) -> Result<SbcPostCommitFault, ConfigError> {
|
||||||
|
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::<u64>().map_err(|_| {
|
||||||
|
ConfigError(
|
||||||
|
"OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT delay must be delay:<milliseconds>"
|
||||||
|
.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:<milliseconds>; 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 {
|
impl HostConfig {
|
||||||
pub fn from_env() -> Result<Self, ConfigError> {
|
pub fn from_env() -> Result<Self, ConfigError> {
|
||||||
let identity_store_path = required("OPENFUT_IDENTITY_STORE")?;
|
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")
|
let clientdata_path = env::var("OPENFUT_CLIENTDATA_DB")
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|v| !v.is_empty())
|
.filter(|v| !v.is_empty())
|
||||||
@@ -89,7 +163,7 @@ impl HostConfig {
|
|||||||
.or_else(|| env::var("FUT_ACCOUNT_PATH").ok().filter(|v| !v.is_empty()))
|
.or_else(|| env::var("FUT_ACCOUNT_PATH").ok().filter(|v| !v.is_empty()))
|
||||||
.unwrap_or_else(|| default_account_path(&identity_store_path));
|
.unwrap_or_else(|| default_account_path(&identity_store_path));
|
||||||
Ok(HostConfig {
|
Ok(HostConfig {
|
||||||
listen_addr: required("OPENFUT_UTAS_HOST_ADDR")?,
|
listen_addr,
|
||||||
python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?,
|
python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?,
|
||||||
core_url: env::var("OPENFUT_CORE_URL")
|
core_url: env::var("OPENFUT_CORE_URL")
|
||||||
.unwrap_or_else(|_| "http://127.0.0.1:8080".into()),
|
.unwrap_or_else(|_| "http://127.0.0.1:8080".into()),
|
||||||
@@ -102,6 +176,7 @@ impl HostConfig {
|
|||||||
identity_store_path,
|
identity_store_path,
|
||||||
clientdata_path,
|
clientdata_path,
|
||||||
account_path,
|
account_path,
|
||||||
|
sbc_post_commit_fault,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,3 +200,57 @@ fn default_account_path(identity_store_path: &str) -> String {
|
|||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.into_owned()
|
.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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1051
-15
File diff suppressed because it is too large
Load Diff
@@ -36,7 +36,7 @@ use crate::economy_store::OwnedItemLookup;
|
|||||||
use crate::market_store::{now_secs, Listing, MarketError, MarketStore};
|
use crate::market_store::{now_secs, Listing, MarketError, MarketStore};
|
||||||
use crate::pile_store::PileStore;
|
use crate::pile_store::PileStore;
|
||||||
use crate::sold_experiment::{CountMode, SoldExperiment};
|
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`).
|
/// FIFA trade-id numbering base (mirrors the oracle's `_TRADE_ID_BASE`).
|
||||||
const TRADE_ID_BASE: i64 = 900_000_000;
|
const TRADE_ID_BASE: i64 = 900_000_000;
|
||||||
@@ -47,6 +47,7 @@ fn json_body(status: u16, body: &Value) -> WireResponse {
|
|||||||
status,
|
status,
|
||||||
headers: vec![("Content-Type".to_string(), "application/json".to_string())],
|
headers: vec![("Content-Type".to_string(), "application/json".to_string())],
|
||||||
body: bytes,
|
body: bytes,
|
||||||
|
transport: ResponseTransport::Normal,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -697,6 +697,18 @@ impl MarketStore {
|
|||||||
.map_err(db)?
|
.map_err(db)?
|
||||||
.rows_affected())
|
.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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -159,6 +159,16 @@ impl PileStore {
|
|||||||
.map(|r| r.get::<String, _>("core_item_id"))
|
.map(|r| r.get::<String, _>("core_item_id"))
|
||||||
.collect())
|
.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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
//! killed on guard drop. Never touches the production Core DB/ports or `.105`.
|
//! 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::catalog::Fifa17CardCatalog;
|
||||||
|
use openfut_adapter_fifa17::fut::club_response::ItemIdentityResolver;
|
||||||
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
||||||
use openfut_adapter_fifa17::fut::non_economy::PERSONA_DISPLAY_NAME;
|
use openfut_adapter_fifa17::fut::non_economy::PERSONA_DISPLAY_NAME;
|
||||||
use openfut_adapter_fifa17::fut::store_session::{SessionStore, StoreMode, SENTINEL_PACK_ID};
|
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.
|
/// Build a real `Server` with economy authority wired against the seeded Core.
|
||||||
/// Returns the server, a direct Core client for balance/entitlement assertions,
|
/// 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.
|
/// the resolver used to obtain stable wire instance ids, and a valid wire
|
||||||
fn build_econ_server(base: &str, dir: &std::path::Path) -> (Server, HttpCoreClient, i64) {
|
/// `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 probe = HttpCoreClient::new(base, "fifa17");
|
||||||
let owned = probe.all_owned().expect("core collection");
|
let owned = probe.all_owned().expect("core collection");
|
||||||
assert!(!owned.is_empty(), "seed must grant a starter 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,
|
PERSONA_ID,
|
||||||
)
|
)
|
||||||
.with_economy(services);
|
.with_economy(services);
|
||||||
(server, probe, 20000)
|
(server, probe, resolver, 20000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────── differential comparison ────────────────────────
|
// ─────────────────────────── differential comparison ────────────────────────
|
||||||
@@ -409,13 +414,31 @@ fn pack_ids(pg: &Value) -> Vec<u64> {
|
|||||||
v
|
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
|
/// Every op runs on a plain OS thread with NO ambient Tokio runtime (the blocking
|
||||||
/// Core client + `reqwest::blocking` require this), exactly like the
|
/// Core client + `reqwest::blocking` require this), exactly like the
|
||||||
/// thread-per-connection server — the bridge takes its direct `block_on` path.
|
/// thread-per-connection server — the bridge takes its direct `block_on` path.
|
||||||
fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||||
wait_ready(core_base);
|
wait_ready(core_base);
|
||||||
let http = reqwest::blocking::Client::new();
|
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. ──
|
// ── Fixture alignment: both sides own exactly one pack-70 entitlement. ──
|
||||||
// Oracle: fresh profile already owns pack 70. Core: grant the "70" entitlement
|
// Oracle: fresh profile already owns pack 70. Core: grant the "70" entitlement
|
||||||
@@ -1240,6 +1263,202 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
|||||||
assert_eq!(matrix.len(), 16, "all economy ops classified");
|
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, 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),
|
||||||
|
(
|
||||||
|
"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"
|
||||||
|
);
|
||||||
|
|
||||||
|
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)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
async fn economy_differential_python_oracle() {
|
async fn economy_differential_python_oracle() {
|
||||||
let dir = std::env::temp_dir().join(format!(
|
let dir = std::env::temp_dir().join(format!(
|
||||||
@@ -1281,3 +1500,35 @@ async fn economy_differential_python_oracle() {
|
|||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
outcome.expect("differential thread panicked");
|
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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
//! touches the production Core DB, production ports/containers, or `.105`.
|
//! touches the production Core DB, production ports/containers, or `.105`.
|
||||||
|
|
||||||
use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog;
|
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_adapter_fifa17::fut::store_session::StoreMode;
|
||||||
use openfut_identity::JsonIdentityStore;
|
use openfut_identity::JsonIdentityStore;
|
||||||
use openfut_utas_host::async_bridge::AsyncBridge;
|
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")
|
.join("active_account.json")
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.into_owned(),
|
.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();
|
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 / ────
|
// ─────────────── Post-barrier authority proofs (NEVER BOTH / no fallback / ────
|
||||||
// stale reader), through the REAL handle_with_ip dispatch ──────
|
// 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(),
|
"/ut/game/fifa17/tradePile/counts".into(),
|
||||||
b"".to_vec(),
|
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(),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ def cmd_parse(args):
|
|||||||
os.makedirs(out_dir, exist_ok=True)
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
out_path = os.path.join(out_dir, "transactions.jsonl")
|
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:
|
with open(out_path, "w") as out:
|
||||||
for conn_id in sorted(conns):
|
for conn_id in sorted(conns):
|
||||||
c = conns[conn_id]
|
c = conns[conn_id]
|
||||||
@@ -298,6 +298,9 @@ def cmd_parse(args):
|
|||||||
m = re.match(r"(\S+)\s+(\S+)\s+(HTTP/\d\.\d)", rl)
|
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, "?")
|
method, target, ver = (m.group(1), m.group(2), m.group(3)) if m else ("?", rl, "?")
|
||||||
path, _, query = target.partition("?")
|
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)
|
st = re.match(r"HTTP/\d\.\d\s+(\d+)", sl)
|
||||||
req_t, req_unix = time_at(c["marks"]["c2s"], rend - 1)
|
req_t, req_unix = time_at(c["marks"]["c2s"], rend - 1)
|
||||||
res_t, res_unix = time_at(c["marks"]["s2c"], max(send - 1, 0))
|
res_t, res_unix = time_at(c["marks"]["s2c"], max(send - 1, 0))
|
||||||
@@ -342,9 +345,14 @@ def cmd_parse(args):
|
|||||||
total += 1
|
total += 1
|
||||||
|
|
||||||
os.chmod(out_path, 0o644)
|
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)"
|
print("wrote %s (%d transactions across %d connections%s)"
|
||||||
% (out_path, total, len(conns),
|
% (out_path, total, len(conns),
|
||||||
"; %d unpaired messages reported above" % skipped if skipped else ""))
|
"; " + "; ".join(detail) if detail else ""))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -587,6 +595,8 @@ def main():
|
|||||||
# fixture for the whole response.
|
# fixture for the whole response.
|
||||||
p.add_argument("--max-body", type=int, default=0,
|
p.add_argument("--max-body", type=int, default=0,
|
||||||
help="bytes; 0 = keep every body in full")
|
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)
|
p.set_defaults(fn=cmd_parse)
|
||||||
|
|
||||||
s = sub.add_parser("snapshot")
|
s = sub.add_parser("snapshot")
|
||||||
|
|||||||
@@ -123,8 +123,8 @@ def main():
|
|||||||
|
|
||||||
body = json.dumps({"squad": [1, 2, 3], "note": "x" * 300}).encode()
|
body = json.dumps({"squad": [1, 2, 3], "note": "x" * 300}).encode()
|
||||||
reqs = [
|
reqs = [
|
||||||
b"GET /ut/game/fifa17/userMassInfo HTTP/1.1\r\nHost: t\r\n\r\n",
|
b"GET /ut/game/fifa17/sbs/sets 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"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"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 /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",
|
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:
|
if len(txs) == 4:
|
||||||
check("methods and paths in order",
|
check("methods and paths in order",
|
||||||
[(t["request"]["method"], t["request"]["path"]) for t in txs] ==
|
[(t["request"]["method"], t["request"]["path"]) for t in txs] ==
|
||||||
[("GET", "/ut/game/fifa17/userMassInfo"),
|
[("GET", "/ut/game/fifa17/sbs/sets"),
|
||||||
("POST", "/ut/game/fifa17/purchased/items"),
|
("POST", "/ut/game/fifa17/sbs/challenge/101/squad"),
|
||||||
("GET", "/chunked"),
|
("GET", "/chunked"),
|
||||||
("GET", "/ut/game/fifa17/hub")])
|
("GET", "/ut/game/fifa17/hub")])
|
||||||
check("request body preserved byte-for-byte",
|
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 is not None and "keep=yes" in qtx["request"]["query"],
|
||||||
qtx["request"]["query"] if qtx else "")
|
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()
|
srv.shutdown()
|
||||||
print()
|
print()
|
||||||
print("all checks passed" if not fails else "%d FAILED: %s" % (len(fails), fails))
|
print("all checks passed" if not fails else "%d FAILED: %s" % (len(fails), fails))
|
||||||
|
|||||||
Reference in New Issue
Block a user