diff --git a/openfut-adapter-fifa17/examples/discard_matrix.rs b/openfut-adapter-fifa17/examples/discard_matrix.rs new file mode 100644 index 0000000..62d18bc --- /dev/null +++ b/openfut-adapter-fifa17/examples/discard_matrix.rs @@ -0,0 +1,167 @@ +//! Emit the discard-pricing matrix for an entire FIFA 17 corpus, and audit it. +//! +//! Uses the SHIPPED implementation (`fut::discard::value_for_definition`) rather +//! than reimplementing the formula, so the matrix cannot drift from what the +//! server actually pays. +//! +//! ```text +//! cargo run -p openfut-adapter-fifa17 --example discard_matrix -- \ +//! [--csv out.csv] +//! ``` +//! +//! Prints an audit summary and, with `--csv`, the full per-definition matrix. + +use std::collections::{BTreeMap, HashMap}; + +use openfut_adapter_fifa17::fut::discard; +use openfut_adapter_fifa17::fut::item::legacy_discard_value; + +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 3 { + eprintln!("usage: discard_matrix [--csv ]"); + std::process::exit(2); + } + let catalog: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&args[1]).expect("read catalog")) + .expect("parse catalog"); + let cards: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&args[2]).expect("read cards")) + .expect("parse cards"); + let csv_path = args + .iter() + .position(|a| a == "--csv") + .map(|i| args[i + 1].clone()); + + // Core's rating per definition id (non-players are 0, which is exactly why + // the catalog rating matters). + let mut core_rating: HashMap = HashMap::new(); + if let Some(arr) = cards.as_array() { + for c in arr { + let id = c["id"].as_str().unwrap_or_default().to_string(); + let r = c["overall"].as_i64().unwrap_or(0).clamp(0, 255) as u8; + core_rating.insert(id, r); + } + } + + let entries = catalog + .get("cards") + .and_then(|c| c.as_object()) + .expect("catalog has cards{}"); + + let mut rows: Vec = Vec::new(); + rows.push("definition,kind,subtype,cardtype,rareflag,rating_src,rating,level,legacy,recovered,verdict".into()); + + let mut by_kind: BTreeMap = BTreeMap::new(); // n, declined, legacy, recovered + let (mut negatives, mut zero_priced, mut declined_total, mut overflow) = + (0usize, 0usize, 0usize, 0usize); + let mut boundary_probe_failures = Vec::new(); + + for (id, e) in entries { + let kind = e["kind"].as_str().unwrap_or("player").to_string(); + let subtype = e["subtype"].as_i64().unwrap_or(0); + let rareflag = e["rareflag"].as_i64().unwrap_or(0); + let cat_rating = e["rating"].as_i64().map(|r| r.clamp(0, 255) as u8); + let core = *core_rating.get(id).unwrap_or(&0); + let cardtype = discard::cardtype_for_subtype(subtype); + + let recovered = discard::value_for_definition(subtype, rareflag, cat_rating, core); + let effective_rating = cat_rating.unwrap_or(core); + let level = discard::discard_level(effective_rating); + let legacy = legacy_discard_value(core); + + let verdict = match recovered { + None => { + declined_total += 1; + "DECLINES->legacy" + } + Some(v) if v < 0 => { + negatives += 1; + "NEGATIVE" + } + Some(0) => { + zero_priced += 1; + "ZERO" + } + Some(v) if v > 1_000_000 => { + overflow += 1; + "IMPLAUSIBLE" + } + Some(_) => "ok", + }; + + let ent = by_kind.entry(kind.clone()).or_insert((0, 0, 0, 0)); + ent.0 += 1; + ent.2 += legacy; + match recovered { + Some(v) => ent.3 += v, + None => { + ent.1 += 1; + ent.3 += legacy; // declining means the legacy ladder is what pays + } + } + + rows.push(format!( + "{id},{kind},{subtype},{cardtype},{rareflag},{},{effective_rating},{level},{legacy},{},{verdict}", + if cat_rating.is_some() { "catalog" } else { "core" }, + recovered.map(|v| v.to_string()).unwrap_or_else(|| "-".into()), + )); + } + + // Rating-boundary audit against the client's own ladder (cmp 0x4b / 0x41). + for (rating, want) in [(0u8, 1u8), (64, 1), (65, 2), (74, 2), (75, 3), (99, 3)] { + let got = discard::discard_level(rating); + if got != want { + boundary_probe_failures.push(format!("rating {rating}: level {got}, expected {want}")); + } + } + + println!("== DISCARD MATRIX AUDIT =="); + println!("definitions : {}", entries.len()); + println!("declined -> legacy : {declined_total}"); + println!("priced zero : {zero_priced}"); + println!("negative : {negatives}"); + println!("implausible (>1e6) : {overflow}"); + println!( + "rating boundaries : {}", + if boundary_probe_failures.is_empty() { + "OK (1/2/3 at <65 / 65..74 / >=75)".to_string() + } else { + boundary_probe_failures.join("; ") + } + ); + println!(); + println!( + "{:<12} {:>6} {:>9} {:>14} {:>14}", + "kind", "n", "declined", "legacy", "recovered" + ); + let (mut tl, mut tr) = (0i64, 0i64); + for (kind, (n, dec, legacy, rec)) in &by_kind { + println!("{kind:<12} {n:>6} {dec:>9} {legacy:>14} {rec:>14}"); + tl += legacy; + tr += rec; + } + println!( + "{:<12} {:>6} {:>9} {:>14} {:>14}", + "TOTAL", + entries.len(), + declined_total, + tl, + tr + ); + if tl > 0 { + println!("ratio recovered/legacy : {:.2}x", tr as f64 / tl as f64); + } + + if let Some(path) = csv_path { + std::fs::write(&path, rows.join("\n") + "\n").expect("write csv"); + println!("\nwrote {} rows to {path}", rows.len() - 1); + } + + let fatal = negatives + overflow + boundary_probe_failures.len(); + if fatal > 0 { + eprintln!("\nFAIL: {fatal} fatal finding(s)"); + std::process::exit(1); + } + println!("\nRESULT: OK"); +} diff --git a/openfut-adapter-fifa17/src/fut/discard.rs b/openfut-adapter-fifa17/src/fut/discard.rs index 988acb4..567ad8c 100644 --- a/openfut-adapter-fifa17/src/fut/discard.rs +++ b/openfut-adapter-fifa17/src/fut/discard.rs @@ -247,6 +247,44 @@ pub fn discard_value(cardtype: u8, rating: u8, rare: i64) -> i64 { (i64::from(rating) * price + 50) / 100 } +/// THE authoritative FIFA 17 discard price for one owned definition, or `None` +/// when an input the client itself uses is not in hand. +/// +/// This is the single entry point every caller must use — the wire shaper and +/// the quick-sell payout both reach it through +/// [`super::item::ItemIdentityResolver::discard_value`], so the number displayed +/// and the number credited cannot diverge. +/// +/// `None` means "not known", never "worthless", and the caller falls back to the +/// legacy ladder rather than paying 0: +/// +/// * `cardtype == 0` — the subtype decodes to no table row at all. +/// * a NON-PLAYER with no `catalog_rating`. Core models every non-player's +/// `overall` as 0, and rating 0 prices at 0 coins, so trusting it would pay +/// nothing for a real card. +/// +/// A PLAYER with no catalog rating legitimately uses Core's rating: cardtype 1 +/// is not re-rated by the client, so Core is authoritative there. For the +/// cardtypes that ARE re-rated ([`client_rerates`]) the catalog rating is the +/// client's own value and must be present, or we cannot match its display. +pub fn value_for_definition( + subtype: i64, + rareflag: i64, + catalog_rating: Option, + core_rating: u8, +) -> Option { + let cardtype = cardtype_for_subtype(subtype); + if cardtype == 0 { + return None; + } + let rating = match catalog_rating { + Some(r) => r, + None if cardtype == 1 => core_rating, + None => return None, + }; + Some(discard_value(cardtype, rating, rareflag)) +} + #[cfg(test)] mod tests { use super::*; @@ -343,6 +381,42 @@ mod tests { assert_eq!(discard_value(10, 67, 0), 37); } + /// A player is priced from Core's rating and the catalog's `rareflag`, so a + /// special and a common of the SAME rating price differently. The legacy + /// ladder paid 1500 for every one of these. + #[test] + fn a_players_price_follows_its_rareflag_not_just_its_rating() { + // rare 3 (TOTW) at level 3 -> price 12200; 90 * 12200 / 100. + assert_eq!(value_for_definition(0, 3, None, 90), Some(10_980)); + // Same rating, rare 0 (gold common) -> 4 * rating. + assert_eq!(value_for_definition(0, 0, None, 90), Some(360)); + // Same rating, rare 1 (gold rare) -> 8 * rating. + assert_eq!(value_for_definition(0, 1, None, 90), Some(720)); + } + + /// A consumable's rating is EA's authored one from the catalog, never Core's + /// 0 — and cardtype 6 is not re-rated, so the server's values are what the + /// client itself prices with. + #[test] + fn a_consumable_prices_from_its_catalog_rating() { + // subtype 201 -> cardtype 6, rating 60 -> level 1, rare 0 -> price 5. + assert_eq!(value_for_definition(201, 0, Some(60), 0), Some(3)); + assert!(!client_rerates(cardtype_for_subtype(201))); + } + + /// The two "not known" cases MUST decline rather than pay 0. + #[test] + fn an_unknown_input_declines_instead_of_paying_zero() { + // Staff: cardtype 10, no catalog rating. Core's rating is 0, which would + // price the card at 0 coins. + assert_eq!(cardtype_for_subtype(6), 10); + assert_eq!(value_for_definition(6, 0, None, 0), None); + // A subtype with no table row at all. + assert_eq!(value_for_definition(600, 0, Some(80), 80), None); + // Once the rating IS known, staff price normally. + assert_eq!(value_for_definition(6, 0, Some(66), 0), Some(36)); + } + /// The subtype decode must agree with the settled club-item subtypes and the /// staff family selector this crate already carries. #[test] diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index f310572..7cbdeb8 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -50,9 +50,7 @@ use std::net::{TcpListener, TcpStream}; use std::sync::{Arc, Mutex}; use std::time::{Instant, SystemTime, UNIX_EPOCH}; -use openfut_adapter_fifa17::fut::catalog::{ - Fifa17CardCatalog, Fifa17CardIdentity, Fifa17WireItemIdPolicy, -}; +use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy}; use openfut_adapter_fifa17::fut::club_response::{ shape_club_response_with_kits, ActiveKitAssignments, CoreOwnedItem, Fifa17ConsumableIdentity, Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver, @@ -1839,7 +1837,12 @@ impl ItemIdentityResolver for Fifa17IdentityResolver { fn discard_value(&self, item: &CoreOwnedItem) -> i64 { if discard_table_enabled() { if let Some(ident) = self.catalog.lookup(&item.card_id) { - if let Some(price) = table_discard_value(&ident, item.rating) { + if let Some(price) = discard::value_for_definition( + ident.subtype, + ident.rareflag, + ident.rating, + item.rating, + ) { return price; } } @@ -4872,33 +4875,6 @@ fn discard_table_enabled() -> bool { *ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_DISCARD_TABLE").as_deref() == Ok("1")) } -/// The client's own discard price for a catalogued definition, or `None` when an -/// input the client uses is not in hand — the caller then keeps the legacy -/// ladder rather than inventing a price or paying 0. -/// -/// `None` cases, all "not known" rather than "worthless": -/// * the `cardsubtypeid` decodes to cardtype 0, which has no table row at all; -/// * a NON-PLAYER with no catalog rating. Core models every non-player's -/// `overall` as 0, and rating 0 prices at 0 coins, so trusting it would pay -/// nothing for a real card. Staff are exactly this case today: their rating is -/// the `value` column of `managercards`/`*coachcards`/`physiocards`, which the -/// import does not yet carry. -/// -/// A PLAYER with no catalog rating legitimately falls back to Core's rating, -/// which is authoritative for cardtype 1 (the client does not re-rate players). -fn table_discard_value(ident: &Fifa17CardIdentity, core_rating: u8) -> Option { - let cardtype = discard::cardtype_for_subtype(ident.subtype); - if cardtype == 0 { - return None; - } - let rating = match ident.rating { - Some(r) => r, - None if cardtype == 1 => core_rating, - None => return None, - }; - Some(discard::discard_value(cardtype, rating, ident.rareflag)) -} - /// A JSON response with an explicit status. fn json_status(status: u16, v: &Value) -> WireResponse { let body = serde_json::to_vec(v).unwrap_or_default(); @@ -5130,70 +5106,6 @@ mod tests { use super::*; use crate::async_bridge::AsyncBridge; - /// A catalog identity carrying only the fields discard pricing reads. - fn priced_def(subtype: i64, rareflag: i64, rating: Option) -> Fifa17CardIdentity { - Fifa17CardIdentity { - asset_id: 1, - version: 0, - resource_id: 1, - rareflag, - kind: ContentKind::Player, - subtype, - card_asset_id: 1, - team_id: 0, - nation: 0, - league_id: 0, - rating, - amount: None, - contract: None, - } - } - - /// A player is priced from Core's rating and the catalog's `rareflag`, on - /// the client's own table — so a special and a common of the SAME rating - /// price differently. The legacy ladder pays 1500 for every one of these. - #[test] - fn a_players_price_follows_its_rareflag_not_just_its_rating() { - // rare 3 (TOTW) at level 3 -> price 12200; 90 * 12200 / 100. - assert_eq!( - table_discard_value(&priced_def(0, 3, None), 90), - Some(10_980) - ); - // Same rating, rare 0 (gold common) -> price 400; 4 * rating. - assert_eq!(table_discard_value(&priced_def(0, 0, None), 90), Some(360)); - // Same rating, rare 1 (gold rare) -> 8 * rating. - assert_eq!(table_discard_value(&priced_def(0, 1, None), 90), Some(720)); - } - - /// A consumable's rating is EA's authored one from the catalog, never Core's - /// 0 — and cardtype 6 is a class the client does NOT re-rate, so the server's - /// values are authoritative. - #[test] - fn a_consumable_prices_from_its_catalog_rating() { - // subtype 201 -> cardtype 6, rating 60 -> level 1, rare 0 -> price 5. - assert_eq!( - table_discard_value(&priced_def(201, 0, Some(60)), 0), - Some(3) - ); - assert!(!discard::client_rerates(discard::cardtype_for_subtype(201))); - } - - /// The two "not known" cases MUST decline to price rather than pay 0. - #[test] - fn an_unknown_input_declines_instead_of_paying_zero() { - // Staff: cardtype 10, no catalog rating (it lives in `gkcoachcards.value`, - // which the import does not carry). Core's rating is 0, which would - // price the card at 0 coins. - assert_eq!(discard::cardtype_for_subtype(6), 10); - assert_eq!(table_discard_value(&priced_def(6, 0, None), 0), None); - // A subtype with no table row at all. - assert_eq!(table_discard_value(&priced_def(600, 0, Some(80)), 80), None); - // But once the rating IS known, staff price normally. - assert_eq!( - table_discard_value(&priced_def(6, 0, Some(66)), 0), - Some(36) - ); - } /// A configurable in-memory economy double: real balance/entitlements, or a /// forced error to prove fail-closed behavior. struct FakeEconomy {