diff --git a/fifa17-recon/docs/plan-2026-08-05-store-subsystem.md b/fifa17-recon/docs/plan-2026-08-05-store-subsystem.md index af75499..4e5c82b 100644 --- a/fifa17-recon/docs/plan-2026-08-05-store-subsystem.md +++ b/fifa17-recon/docs/plan-2026-08-05-store-subsystem.md @@ -607,6 +607,45 @@ cardtype 6, live-confirmed on the two resident consumables, so for exactly the items the warning was aimed at, the server's rating and rare flag are authoritative. +**APPLIED (2026-08-21), behind a default-off flag.** The table and the formula +above are now in Rust as `openfut-adapter-fifa17::fut::discard`: +`cardtype_for_subtype` is the decode, `discard_level` the 3/2/1 ladder, +`table_price` the 141-row lookup (`0` for an absent key) and `discard_value` the +`round_half_up(rating * price / 100)` formula. `DISCARD_COINS` is generated from +`fifa17-recon/data/tables/fcc_discardcoins.json` and a test re-reads that file +and asserts they still agree row for row, so the two cannot drift. The four +worked examples above (`8 * rating`, `4 * rating`, the 50-rated bronze at 15, +and an absent key paying 0) are tests. + +Wire and wallet are now ONE method. `ItemIdentityResolver::discard_value` both +stamps the card's `discardValue` and prices the sale, because a non-zero +`discardValue` suppresses the client's local computation — so whatever is sent +is what the player is promised. The host's separate `quick_sell_value` ladder is +deleted (it was a second copy that could drift), and a test with a resolver +double returning an impossible price proves the credit follows the wire. + +`OPENFUT_FIFA17_DISCARD_TABLE=1` turns the table on; the default keeps the old +placeholder ladder because switching revalues an existing club by **10.5x** +(measured over the real 1991-item club: 1,820,400 -> 19,128,955 coins if wholly +liquidated). Players drive it (an r93 special goes 1500 -> 74,400); consumables +move the OTHER way (2,400 -> 437, i.e. the ladder was overpaying 5.5x). + +NOT yet exact: the five staff families. Measured on staging, a staff wire record +carries NO `rating`, NO `rareflag` and NO `discardValue`, so the client prices it +wholly from its own re-rate of cardtypes 2/3/4/5/10 and the server never sees the +number. The catalog's `rating` is `None` and Core's `overall` is 0, so pricing +DECLINES for staff and falls back to the ladder rather than paying 0 coins. The +server therefore pays 150 while the client displays something else — a divergence +that PRE-DATES this work (the old ladder paid the same 150 and also sent no +`discardValue`). + +It is left open deliberately. The only rating-shaped column in those five tables +is `value` (66 GK coach, 88 manager), but that `value` is the discard rating is an +INFERENCE: §3.6 names the tables and never the column. Importing it would guess an +economy, which is what this change removed. FALSIFIER, one launch: read the +discard value the client shows on a staff card — 36 on the `value`-66 GK coach +confirms it and the fix is to carry `value` through `openfut-import-fifa17`. + ### 3.7 `duplicateItemIdList` CONFIRMED shape, INFERRED effect, never observed. Element deser `FUN_180138e10`, diff --git a/openfut-adapter-fifa17/src/fut/club_response.rs b/openfut-adapter-fifa17/src/fut/club_response.rs index a64e662..b3a7b19 100644 --- a/openfut-adapter-fifa17/src/fut/club_response.rs +++ b/openfut-adapter-fifa17/src/fut/club_response.rs @@ -54,7 +54,7 @@ pub fn shape_club_response_with_kits( match ident.kind_of(item) { ContentKind::Player => match ident.resolve(item) { Some(id) => { - out.push(shape_item(item, id, ent)); + out.push(shape_item(item, id, ent, ident.discard_value(item))); stats.emitted += 1; } None => stats.dropped_no_asset += 1, diff --git a/openfut-adapter-fifa17/src/fut/discard.rs b/openfut-adapter-fifa17/src/fut/discard.rs new file mode 100644 index 0000000..988acb4 --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/discard.rs @@ -0,0 +1,380 @@ +//! FIFA 17 **discard (quick-sell) pricing** — the client's OWN table and formula. +//! +//! Nothing here is invented. The client computes a card's discard value locally +//! whenever the server sends `discardValue == 0` (`FUN_18013fe00` stores our +//! value at item `+0x38`; `0x180141025` `cmp dword [rbp+0x198],0` / `ja` skips +//! the local path when it is non-zero). The local path runs +//! +//! ```sql +//! SELECT "price" FROM "fcc_discardcoins" WHERE "cardtype"==? AND "level"==? AND "rare"==? +//! ``` +//! +//! and then, at `0x180141119..0x180141140`: +//! +//! ```text +//! value = (rating * price) / 100, rounded half up +//! ``` +//! +//! Sources, all in-repo: +//! * `fifa17-recon/docs/plan-2026-08-05-store-subsystem.md` §3.6 — the SQL, the +//! formula, the `cardsubtypeid -> cardtype` decode (checked against every +//! subtype `0..599` with zero disagreements) and the `level` ladder. +//! * `fifa17-recon/data/tables/fcc_discardcoins.json` — the 141-row table itself. +//! [`DISCARD_COINS`] is generated from that file and a test re-reads the file +//! and asserts they still agree row for row, so the two cannot drift. +//! +//! The reversal was **verified against 22 live club items, 22 of 22 exact**. +//! +//! A key that is not in the table pays `0` (the client's price register stays 0), +//! so [`table_price`] returns `0` rather than panicking or substituting a floor. + +/// `(cardtype, level, rare, price)`, generated verbatim from the client's +/// `fcc_discardcoins` table. Sorted; keys are unique. +const DISCARD_COINS: [(u8, u8, u8, i64); 141] = [ + (1, 1, 0, 30), + (1, 1, 1, 75), + (1, 1, 2, 2000), + (1, 1, 3, 2000), + (1, 1, 4, 6000), + (1, 1, 5, 20000), + (1, 1, 6, 20000), + (1, 1, 7, 1500), + (1, 1, 8, 6000), + (1, 1, 9, 6000), + (1, 1, 10, 2000), + (1, 1, 11, 10000), + (1, 1, 12, 120000), + (1, 1, 13, 2000), + (1, 1, 17, 2000), + (1, 1, 18, 2000), + (1, 1, 19, 2000), + (1, 1, 20, 2000), + (1, 1, 21, 2000), + (1, 1, 22, 2000), + (1, 1, 23, 2000), + (1, 1, 24, 2000), + (1, 1, 25, 2000), + (1, 1, 26, 2000), + (1, 1, 27, 2000), + (1, 1, 28, 2000), + (1, 1, 29, 2000), + (1, 1, 30, 2000), + (1, 1, 31, 2000), + (1, 2, 0, 150), + (1, 2, 1, 350), + (1, 2, 2, 7000), + (1, 2, 3, 7000), + (1, 2, 4, 10000), + (1, 2, 5, 40000), + (1, 2, 6, 40000), + (1, 2, 7, 5000), + (1, 2, 8, 10000), + (1, 2, 9, 10000), + (1, 2, 10, 7000), + (1, 2, 11, 15000), + (1, 2, 12, 120000), + (1, 2, 13, 7000), + (1, 2, 17, 7000), + (1, 2, 18, 7000), + (1, 2, 19, 7000), + (1, 2, 20, 7000), + (1, 2, 21, 7000), + (1, 2, 22, 7000), + (1, 2, 23, 7000), + (1, 2, 24, 7000), + (1, 2, 25, 7000), + (1, 2, 26, 7000), + (1, 2, 27, 7000), + (1, 2, 28, 7000), + (1, 2, 29, 7000), + (1, 2, 30, 7000), + (1, 2, 31, 7000), + (1, 3, 0, 400), + (1, 3, 1, 800), + (1, 3, 2, 12200), + (1, 3, 3, 12200), + (1, 3, 4, 18000), + (1, 3, 5, 80000), + (1, 3, 6, 80000), + (1, 3, 7, 9000), + (1, 3, 8, 18000), + (1, 3, 9, 18000), + (1, 3, 10, 12200), + (1, 3, 11, 24000), + (1, 3, 12, 120000), + (1, 3, 13, 12200), + (1, 3, 17, 12200), + (1, 3, 18, 12200), + (1, 3, 19, 12200), + (1, 3, 20, 12200), + (1, 3, 21, 12200), + (1, 3, 22, 12200), + (1, 3, 23, 12200), + (1, 3, 24, 12200), + (1, 3, 25, 12200), + (1, 3, 26, 12200), + (1, 3, 27, 12200), + (1, 3, 28, 12200), + (1, 3, 29, 12200), + (1, 3, 30, 12200), + (1, 3, 31, 12200), + (2, 1, 0, 20), + (2, 1, 1, 25), + (2, 2, 0, 70), + (2, 2, 1, 120), + (2, 3, 0, 110), + (2, 3, 1, 320), + (3, 1, 0, 10), + (3, 1, 1, 50), + (3, 2, 0, 55), + (3, 2, 1, 100), + (3, 3, 0, 110), + (3, 3, 1, 300), + (4, 1, 0, 10), + (4, 1, 1, 50), + (4, 2, 0, 55), + (4, 2, 1, 100), + (4, 3, 0, 110), + (4, 3, 1, 300), + (5, 1, 0, 10), + (5, 1, 1, 50), + (5, 2, 0, 55), + (5, 2, 1, 100), + (5, 3, 0, 110), + (5, 3, 1, 300), + (6, 1, 0, 5), + (6, 1, 1, 20), + (6, 2, 0, 20), + (6, 2, 1, 50), + (6, 3, 0, 40), + (6, 3, 1, 70), + (7, 1, 0, 5), + (7, 1, 1, 20), + (7, 2, 0, 20), + (7, 2, 1, 50), + (7, 3, 0, 40), + (7, 3, 1, 70), + (8, 1, 0, 5), + (8, 1, 1, 20), + (8, 2, 0, 20), + (8, 2, 1, 50), + (8, 3, 0, 40), + (8, 3, 1, 70), + (9, 1, 0, 5), + (9, 1, 1, 20), + (9, 2, 0, 20), + (9, 2, 1, 50), + (9, 3, 0, 40), + (9, 3, 1, 70), + (10, 1, 0, 10), + (10, 1, 1, 50), + (10, 2, 0, 55), + (10, 2, 1, 100), + (10, 3, 0, 110), + (10, 3, 1, 300), +]; + +/// The `cardsubtypeid -> cardtype` decode (`FUN_1800d8330`, read out of its raw +/// two-level jump table). `0` = no table row, which prices at `0`. +/// +/// The staff arms agree independently with +/// [`super::content_taxonomy::staff_role`]'s family selector (4=manager, +/// 5=headcoach, 6=gkcoach, 7=physio, 8=fitnesscoach) and with the five card +/// tables the client re-queries for those cardtypes — see [`client_rerates`]. +pub fn cardtype_for_subtype(subtype: i64) -> u8 { + match subtype { + 0..=3 => 1, + 4 => 2, + 5 => 3, + 6 => 10, + 7 => 5, + 8 => 4, + 9..=11 => 7, + 30 | 31 | 145..=150 | 231..=233 | 236 => 9, + 51..=136 | 201..=220 | 250..=273 | 300..=341 => 6, + _ => 0, + } +} + +/// Discard `level` from rating: `3` if `>= 75`, `2` if `65..=74`, else `1`. +/// +/// Derived purely from rating at the tail of `FUN_180141660` +/// (`0x180141e8a..0x180141ea3`). It is NOT a wire field — the slot at item +/// `+0x54` is never written through the deserializer's frame. +pub const fn discard_level(rating: u8) -> u8 { + if rating >= 75 { + 3 + } else if rating >= 65 { + 2 + } else { + 1 + } +} + +/// Whether the client OVERWRITES the rating and rare flag we send with values +/// from its own card database before pricing. +/// +/// True for cardtypes 2, 3, 4, 5 and 10 (the staff families — it re-queries +/// `managercards`, `headcoachcards`, `fitnesscoachcards`, `physiocards` and +/// `gkcoachcards` by `carddbid`). For cardtypes 6, 7, 8 and 9 the jump table at +/// rva `0x141eb4` goes straight to the default arm with no DB query and no +/// overwrite, so for consumables and club items the server's values are +/// authoritative. +pub const fn client_rerates(cardtype: u8) -> bool { + matches!(cardtype, 2 | 3 | 4 | 5 | 10) +} + +/// `fcc_discardcoins` price for a key, or `0` when the table has no such row. +pub fn table_price(cardtype: u8, level: u8, rare: i64) -> i64 { + if !(0..=255).contains(&rare) { + return 0; + } + let rare = rare as u8; + DISCARD_COINS + .iter() + .find(|&&(c, l, r, _)| c == cardtype && l == level && r == rare) + .map_or(0, |&(_, _, _, price)| price) +} + +/// The client's discard value for a card: `round_half_up(rating * price / 100)`. +/// +/// Returns `0` for a key the table does not carry, exactly as the client does. +pub fn discard_value(cardtype: u8, rating: u8, rare: i64) -> i64 { + let price = table_price(cardtype, discard_level(rating), rare); + if price == 0 { + return 0; + } + (i64::from(rating) * price + 50) / 100 +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The generated table MUST still equal the client's own file, row for row. + /// This is what makes [`DISCARD_COINS`] a transcription of evidence rather + /// than a hand-authored economy. + #[test] + fn the_table_still_matches_the_clients_own_file() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../fifa17-recon/data/tables/fcc_discardcoins.json" + ); + let raw = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("client discard table missing at {path}: {e}")); + let doc: serde_json::Value = serde_json::from_str(&raw).expect("table is JSON"); + assert_eq!(doc["table"], "fcc_discardcoins"); + let declared = doc["rowcount"].as_u64().expect("rowcount") as usize; + let rows = doc["rows"].as_array().expect("rows array"); + assert_eq!(rows.len(), declared, "file disagrees with its own rowcount"); + + let mut from_file: Vec<(u8, u8, u8, i64)> = rows + .iter() + .map(|r| { + let g = |k: &str| r.get(k).and_then(serde_json::Value::as_i64).unwrap(); + ( + g("cardtype") as u8, + g("level") as u8, + g("rare") as u8, + g("price"), + ) + }) + .collect(); + from_file.sort_unstable(); + + let mut ours = DISCARD_COINS.to_vec(); + ours.sort_unstable(); + assert_eq!( + ours, from_file, + "generated table drifted from the client file" + ); + assert_eq!(from_file.len(), 141); + } + + /// The four worked examples stated in the reversal, which was checked + /// against 22 live club items. + #[test] + fn the_documented_live_verified_prices_reproduce() { + // "A gold rare player is 8 * rating: 75 gives 600, 94 gives 752." + assert_eq!(discard_value(1, 94, 1), 752); + assert_eq!(discard_value(1, 75, 1), 600); + for rating in 75..=99u8 { + assert_eq!(discard_value(1, rating, 1), 8 * i64::from(rating)); + } + // "A gold common is 4 * rating." + for rating in 75..=99u8 { + assert_eq!(discard_value(1, rating, 0), 4 * i64::from(rating)); + } + // "A 50-rated bronze common is 15." + assert_eq!(discard_value(1, 50, 0), 15); + } + + /// "A key we do not have pays 0, so use a `.get(key, 0)`, not a subscript." + #[test] + fn an_absent_key_pays_zero_and_never_a_floor() { + // rare 14, 15, 16 are absent for cardtype 1 ... + for rare in [14, 15, 16] { + assert_eq!(table_price(1, 3, rare), 0); + assert_eq!(discard_value(1, 94, rare), 0); + } + // ... and cardtypes 2..=10 carry only rare 0 and 1. + for cardtype in 2..=10u8 { + assert_eq!(table_price(cardtype, 3, 2), 0); + } + // A subtype outside every documented range decodes to cardtype 0. + assert_eq!(cardtype_for_subtype(600), 0); + assert_eq!(discard_value(0, 94, 1), 0); + } + + #[test] + fn the_level_ladder_is_the_clients() { + assert_eq!(discard_level(74), 2); + assert_eq!(discard_level(75), 3); + assert_eq!(discard_level(65), 2); + assert_eq!(discard_level(64), 1); + assert_eq!(discard_level(0), 1); + } + + /// Rounding is half UP, not truncation: 66 * 55 / 100 = 36.3 -> 36, but + /// 67 * 55 / 100 = 36.85 -> 37. + #[test] + fn the_rounding_is_half_up() { + assert_eq!(discard_value(10, 66, 0), 36); + assert_eq!(discard_value(10, 67, 0), 37); + } + + /// The subtype decode must agree with the settled club-item subtypes and the + /// staff family selector this crate already carries. + #[test] + fn the_decode_agrees_with_the_settled_subtypes() { + use crate::fut::content_taxonomy as tax; + // Kit, stadium and badge are cardtype 7 ... + for s in [tax::KIT_SUBTYPE, tax::STADIUM_SUBTYPE, tax::BADGE_SUBTYPE] { + assert_eq!(cardtype_for_subtype(s), 7, "subtype {s}"); + } + // ... ball and league logo are cardtype 9 ... + for s in [tax::BALL_SUBTYPE, tax::LEAGUE_LOGO_SUBTYPE] { + assert_eq!(cardtype_for_subtype(s), 9, "subtype {s}"); + } + // ... and `fcc_misccards` is cardtype 9 too. + for s in [231, 232, 233, 236] { + assert_eq!(cardtype_for_subtype(s), 9, "subtype {s}"); + } + // A manager is cardtype 2, and every staff subtype is one the client + // re-rates from its own database. + assert_eq!(cardtype_for_subtype(tax::MANAGER_SUBTYPE), 2); + for subtype in 4..=8 { + assert!( + client_rerates(cardtype_for_subtype(subtype)), + "staff subtype {subtype} must be client-re-rated" + ); + assert!(tax::staff_role(subtype).is_some()); + } + // Consumables are cardtype 6, and the server's values ARE authoritative + // for them. + for subtype in [52, 54, 92, 98, 100] { + assert_eq!(cardtype_for_subtype(subtype), 6); + assert!(!client_rerates(6)); + } + } +} diff --git a/openfut-adapter-fifa17/src/fut/item.rs b/openfut-adapter-fifa17/src/fut/item.rs index 0fc6f1e..de29c5d 100644 --- a/openfut-adapter-fifa17/src/fut/item.rs +++ b/openfut-adapter-fifa17/src/fut/item.rs @@ -208,6 +208,25 @@ pub trait ItemIdentityResolver { None } + /// The card's discard (quick-sell) value in coins — BOTH the number the + /// client displays on the card and the number the server MUST credit when + /// it is sold. One method serves both so the wire and the wallet cannot + /// disagree: a non-zero `discardValue` suppresses the client's own local + /// computation (`0x180141025`), so whatever is sent here is what the player + /// is promised. + /// + /// NON-MINTING by contract, like [`Self::subtype_of`] — it takes the Core + /// item, never a resolved [`Fifa17Identity`], so pricing a card on a read + /// path cannot allocate a wire id. + /// + /// The default is the [`legacy_discard_value`] placeholder ladder, which + /// preserves the behaviour of every resolver without a catalog behind it. + /// The catalog-backed FIFA17 resolver overrides it with the client's own + /// `fcc_discardcoins` table (see [`super::discard`]). + fn discard_value(&self, item: &CoreOwnedItem) -> i64 { + legacy_discard_value(item.rating) + } + /// The FIFA `cardsubtypeid` of a Core item's definition, or `0` when unknown /// or a player. NON-MINTING by contract: `/club`'s per-family filters call it /// for every owned row, so allocating a wire id here would pollute the @@ -245,9 +264,17 @@ pub struct ShapeStats { pub excluded_non_player: usize, } -/// Quick-sell / discard value by rating tier (mirrors Core's quick-sell table; -/// non-fatal display field). -fn discard_value(rating: u8) -> i64 { +/// The ORIGINAL rating-only quick-sell ladder. **A placeholder, not +/// EA-authentic**: it is blind to both card type and `rareflag`, so it prices a +/// 94-rated TOTW special and a 94-rated gold common identically, and it pays a +/// flat floor for every non-player (whose Core rating is 0). +/// +/// The client's real value is `round_half_up(rating * fcc_discardcoins.price / +/// 100)` — see [`super::discard`], which reproduces it exactly. This ladder is +/// retained as the [`ItemIdentityResolver::discard_value`] default so a resolver +/// with no catalog behind it keeps its existing behaviour, and so the deployed +/// economy only changes when an operator opts in. +pub fn legacy_discard_value(rating: u8) -> i64 { match rating { r if r >= 85 => 1500, r if r >= 80 => 900, @@ -271,6 +298,7 @@ pub fn shape_item( item: &CoreOwnedItem, id: Fifa17Identity, ent: &impl ReverseEntityResolver, + discard_value: i64, ) -> Value { let asset = id.asset_id; let league_id = ent.league_id(&item.league).unwrap_or(0); @@ -308,7 +336,7 @@ pub fn shape_item( "untradeable": false, "contract": 7, "fitness": 99, - "discardValue": discard_value(item.rating), + "discardValue": discard_value, }) } @@ -533,6 +561,7 @@ mod tests { rareflag: 1, }, &ent, + legacy_discard_value(86), ); assert_eq!(it["id"], 100000001, "wire instance id"); assert_eq!(it["resourceId"], 20801); @@ -566,6 +595,7 @@ mod tests { rareflag: 1, }, &ent, + legacy_discard_value(84), ); let b = shape_item( &item("oc-b", "fifa17_101490", 84, "ST"), @@ -576,6 +606,7 @@ mod tests { rareflag: 1, }, &ent, + legacy_discard_value(84), ); assert_eq!( a["resourceId"], b["resourceId"], @@ -604,6 +635,7 @@ mod tests { rareflag: 3, }, &ent, + legacy_discard_value(92), ); assert_eq!( it["resourceId"], 117617092, @@ -708,6 +740,7 @@ mod tests { rareflag: 1, }, &ent, + legacy_discard_value(86), ); emitted.push(player["itemState"].as_str().unwrap().to_string()); let staff = shape_staff_item( diff --git a/openfut-adapter-fifa17/src/fut/mod.rs b/openfut-adapter-fifa17/src/fut/mod.rs index 3be9455..7e1844c 100644 --- a/openfut-adapter-fifa17/src/fut/mod.rs +++ b/openfut-adapter-fifa17/src/fut/mod.rs @@ -9,6 +9,7 @@ pub mod club_response; pub mod club_stats; pub mod consumables; pub mod content_taxonomy; +pub mod discard; pub mod economy; pub mod economy_policy; pub mod entities; diff --git a/openfut-adapter-fifa17/src/fut/squad_projection.rs b/openfut-adapter-fifa17/src/fut/squad_projection.rs index cf14933..38a06d6 100644 --- a/openfut-adapter-fifa17/src/fut/squad_projection.rs +++ b/openfut-adapter-fifa17/src/fut/squad_projection.rs @@ -166,7 +166,7 @@ pub fn project_squad( .unwrap_or(0); players.push(json!({ "index": index, - "itemData": shape_item(item, id, ent), + "itemData": shape_item(item, id, ent, ident.discard_value(item)), "kitNumber": kit, })); } diff --git a/openfut-utas-host/src/economy_store.rs b/openfut-utas-host/src/economy_store.rs index 7b7ceec..3706250 100644 --- a/openfut-utas-host/src/economy_store.rs +++ b/openfut-utas-host/src/economy_store.rs @@ -162,7 +162,12 @@ fn shape_minted(deps: &StoreDeps<'_>, minted: &[Minted]) -> Vec { .filter_map(|m| { let item = core_owned(m); let id = deps.assets.resolve(&item)?; - Some(shape_item(&item, id, deps.entities)) + Some(shape_item( + &item, + id, + deps.entities, + deps.assets.discard_value(&item), + )) }) .collect() } @@ -371,22 +376,13 @@ pub struct QuickSellDeps<'a> { /// uses). Identity only; ownership is authorized by [`OwnedItemLookup`]. pub reverse: &'a dyn SquadWireResolver, pub items: &'a dyn OwnedItemLookup, -} - -/// OPENFUT CURRENT quick-sell value by rating (PLACEHOLDER, not EA-authentic). -/// Mirrors the on-wire `discardValue` that -/// `openfut_adapter_fifa17::fut::item` stamps, so the coins credited equal the -/// value the client displayed. (The Python oracle used a *different* invented -/// fallback — 600/300/150/50, `fut_store.py:505` — which disagreed with the wire -/// `discardValue`; crediting the displayed figure keeps them consistent.) -fn quick_sell_value(rating: u8) -> i64 { - match rating { - r if r >= 85 => 1500, - r if r >= 80 => 900, - r if r >= 75 => 600, - r if r >= 65 => 300, - _ => 150, - } + /// Prices the sale. This is the SAME resolver, and the same method, that + /// stamps `discardValue` onto the shaped card, so the coins credited are by + /// construction the number the client displayed — there is no second ladder + /// to drift. (The Python oracle had exactly that bug: an invented + /// 600/300/150/50 fallback in `fut_store.py:505` that disagreed with the + /// wire `discardValue`.) + pub assets: &'a dyn ItemIdentityResolver, } /// Quick-sell every owned card in `wire_ids` (server-priced). Skips ids that do @@ -409,7 +405,10 @@ pub fn handle_quick_sell(wire_ids: &[i64], deps: &QuickSellDeps<'_>) -> WireResp Some(i) => i, None => continue, // resolvable id, but not owned: never sold }; - match deps.econ.sell_item(&core_id, quick_sell_value(item.rating)) { + match deps + .econ + .sell_item(&core_id, deps.assets.discard_value(&item)) + { Ok(balance) => { last_balance = Some(balance); sold_ids.push(wire); @@ -990,6 +989,16 @@ mod tests { } } + /// Prices sales with the trait's DEFAULT (legacy ladder) implementation, so + /// these tests pin the deployed behaviour: no catalog, no table. + struct LadderAssets; + impl ItemIdentityResolver for LadderAssets { + fn resolve(&self, _item: &CoreOwnedItem) -> Option { + None + } + } + static LADDER_ASSETS: LadderAssets = LadderAssets; + fn qs_deps<'a>( econ: &'a RecEcon, reverse: &'a FakeReverse, @@ -999,6 +1008,7 @@ mod tests { econ, reverse, items, + assets: &LADDER_ASSETS, } } @@ -1019,6 +1029,64 @@ mod tests { assert_eq!(sold[0], ("c1".to_string(), 1500)); } + /// THE INVARIANT: the coins credited are whatever + /// [`ItemIdentityResolver::discard_value`] says — the SAME method, on the + /// SAME resolver, that stamps `discardValue` onto the shaped card. A second + /// pricing ladder living in this module is exactly the drift this pins + /// against, so the double returns a value no ladder could produce. + #[test] + fn the_sale_credits_whatever_priced_the_card_on_the_wire() { + struct OddPriced; + impl ItemIdentityResolver for OddPriced { + fn resolve(&self, _item: &CoreOwnedItem) -> Option { + None + } + fn discard_value(&self, _item: &CoreOwnedItem) -> i64 { + 10_980 + } + } + let econ = RecEcon::new(1000); + let reverse = FakeReverse(HashMap::from([(100_000_001, "c1".to_string())])); + // Rating 88 -> the legacy ladder would pay 1500. The resolver must win. + let items = FakeItems(HashMap::from([("c1".to_string(), owned("c1", 88))])); + let deps = QuickSellDeps { + econ: &econ, + reverse: &reverse, + items: &items, + assets: &OddPriced, + }; + let resp = handle_quick_sell_path(100_000_001, &deps); + assert_eq!(resp.status, 200); + let b: Value = serde_json::from_slice(&resp.body).unwrap(); + assert_eq!(b["totalCredits"], 11_980, "1000 + the card's own price"); + assert_eq!(econ.sold.lock()[0], ("c1".to_string(), 10_980)); + } + + /// The trait default MUST stay the deployed ladder, so a resolver with no + /// catalog behind it prices exactly as it did before the table existed. + #[test] + fn the_default_price_is_still_the_legacy_ladder() { + for (rating, expected) in [ + (94u8, 1500i64), + (88, 1500), + (82, 900), + (77, 600), + (66, 300), + (50, 150), + (0, 150), + ] { + assert_eq!( + LADDER_ASSETS.discard_value(&owned("c", rating)), + expected, + "rating {rating}" + ); + assert_eq!( + openfut_adapter_fifa17::fut::item::legacy_discard_value(rating), + expected + ); + } + } + #[test] fn quick_sell_body_form_and_alias_match_path_form() { for req in [ diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index f8c889e..16ca681 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -50,7 +50,9 @@ use std::net::{TcpListener, TcpStream}; use std::sync::{Arc, Mutex}; use std::time::{Instant, SystemTime, UNIX_EPOCH}; -use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy}; +use openfut_adapter_fifa17::fut::catalog::{ + Fifa17CardCatalog, Fifa17CardIdentity, Fifa17WireItemIdPolicy, +}; use openfut_adapter_fifa17::fut::club_response::{ shape_club_response_with_kits, ActiveKitAssignments, CoreOwnedItem, Fifa17ConsumableIdentity, Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver, @@ -60,6 +62,7 @@ use openfut_adapter_fifa17::fut::consumables::consumables_response; use openfut_adapter_fifa17::fut::content_taxonomy::{ consumable_families_for_category, consumable_family, position_group, ContentKind, PositionGroup, }; +use openfut_adapter_fifa17::fut::discard; use openfut_adapter_fifa17::fut::entities::{Fifa17Entities, ReverseEntityResolver}; use openfut_adapter_fifa17::fut::item::CONSUMABLE_UNTRADEABLE; use openfut_adapter_fifa17::fut::match_wire; @@ -1800,6 +1803,31 @@ impl ItemIdentityResolver for Fifa17IdentityResolver { }) } + /// Price a card from the CLIENT'S OWN `fcc_discardcoins` table when + /// `OPENFUT_FIFA17_DISCARD_TABLE=1`, else keep the legacy placeholder ladder. + /// + /// Non-minting: it reads the catalog directly and never calls `wire_for`. + /// + /// Falls back to the ladder — never to a fabricated or zero price — when the + /// inputs the client uses are not in hand: + /// * the definition is not in the catalog at all; or + /// * the subtype decodes to cardtype 0 (no table row); or + /// * a NON-PLAYER carries no catalog rating. Core models a non-player's + /// `overall` as 0, and 0 would price the card at 0 coins, so an absent + /// rating means "not known", not "worthless". This is currently the case + /// for staff, whose rating lives in the `value` column of + /// `managercards`/`*coachcards`/`physiocards` and is not yet imported. + 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) { + return price; + } + } + } + openfut_adapter_fifa17::fut::item::legacy_discard_value(item.rating) + } + fn resolve_kit(&self, item: &CoreOwnedItem) -> Option { let ident = self.catalog.lookup(&item.card_id)?; // The whole cardtype-7 club family shares this record: kit, stadium and @@ -3692,6 +3720,7 @@ impl Server { econ: svc.econ.as_ref(), reverse: self.resolver.as_ref(), items: &lookup, + assets: self.resolver.as_ref(), }; handle_quick_sell_path(id, &deps) } @@ -3703,6 +3732,7 @@ impl Server { econ: svc.econ.as_ref(), reverse: self.resolver.as_ref(), items: &lookup, + assets: self.resolver.as_ref(), }; handle_quick_sell_body(body, &deps) } @@ -4802,6 +4832,54 @@ fn commerce_settings_enabled() -> bool { *ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_COMMERCE_SETTINGS").as_deref() == Ok("1")) } +/// Whether quick-sell pricing uses the client's own `fcc_discardcoins` table +/// instead of the legacy rating-only ladder. +/// +/// OFF unless `OPENFUT_FIFA17_DISCARD_TABLE=1`. The table is the higher-fidelity +/// answer — it is the client's own data, reproduces its formula +/// `round_half_up(rating * price / 100)`, and was verified against 22 live club +/// items, 22 of 22 exact — but the ladder is what the DEPLOYED economy has been +/// paying, and switching revalues an existing club in BOTH directions (a +/// level-3 TOTW special goes 1500 -> 10980; a 50-rated bronze common goes +/// 150 -> 15). That is an operator's decision, not a silent upgrade, so the +/// house rule applies: the flag defaults to the deployed value. +/// +/// It gates the wire and the wallet TOGETHER. `discardValue` is what the client +/// displays, and [`ItemIdentityResolver::discard_value`] is the single source +/// for both the shaped card and the coins credited on sale, so the two can never +/// disagree in either mode. +fn discard_table_enabled() -> bool { + static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + *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(); @@ -5033,6 +5111,70 @@ 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 { diff --git a/openfut-utas-host/src/market.rs b/openfut-utas-host/src/market.rs index 431f3c1..94a0512 100644 --- a/openfut-utas-host/src/market.rs +++ b/openfut-utas-host/src/market.rs @@ -284,7 +284,7 @@ pub fn resolve_market_list( let identity = resolver.resolve(&owned); let resource_id = identity.map(|id| id.resource_id as i64); let item_json = identity - .map(|id| shape_item(&owned, id, ent)) + .map(|id| shape_item(&owned, id, ent, resolver.discard_value(&owned))) .and_then(|card| serde_json::to_string(&card).ok()); Some(ResolvedListing { item_id, diff --git a/openfut-utas-host/tests/economy_concurrency.rs b/openfut-utas-host/tests/economy_concurrency.rs index 06be422..7d12bfc 100644 --- a/openfut-utas-host/tests/economy_concurrency.rs +++ b/openfut-utas-host/tests/economy_concurrency.rs @@ -226,16 +226,11 @@ fn set_balance(client: &HttpCoreClient, target: i64) { assert_eq!(client.balance().unwrap(), target, "balance set"); } -/// The on-wire quick-sell `discardValue` tiers (host `economy_store::quick_sell_value`), -/// replicated to assert the EXACT credit — the client-visible contract. +/// The on-wire quick-sell `discardValue`, taken from the ONE implementation the +/// server uses rather than replicated here: a local copy silently passes while +/// the real price changes underneath it. fn qs_value(rating: u8) -> i64 { - match rating { - r if r >= 85 => 1500, - r if r >= 80 => 900, - r if r >= 75 => 600, - r if r >= 65 => 300, - _ => 150, - } + openfut_adapter_fifa17::fut::item::legacy_discard_value(rating) } fn owns(client: &HttpCoreClient, core_id: &str) -> bool {