fifa17: price quick-sell from the client's own discard table

Quick-sell paid an invented five-tier rating ladder (its own comment said
"PLACEHOLDER, not EA-authentic"). It was blind to card type and rareflag, so a
94-rated TOTW special and a 94-rated gold common both sold for 1500, and every
non-player -- whose Core overall is 0 -- sold for the flat 150 floor. The ladder
existed in three places (adapter wire, host payout, an integration test's private
copy), which is a drift waiting to happen.

Add openfut-adapter-fifa17::fut::discard: the client's own fcc_discardcoins
table and its formula, round_half_up(rating * price / 100), keyed
(cardtype, level, rare). All of it is already reversed in
plan-2026-08-05-store-subsystem.md 3.6 and was verified there against 22 live
club items, 22 of 22 exact. DISCARD_COINS is generated from
fifa17-recon/data/tables/fcc_discardcoins.json and a test re-reads that file and
asserts row-for-row agreement, so the transcription cannot drift.

Collapse the three ladders into one method. ItemIdentityResolver::discard_value
both stamps the wire discardValue and prices the sale, because a non-zero
discardValue suppresses the client's local computation -- whatever is sent is
what the player is promised. The host's quick_sell_value is deleted and the
integration test's copy now calls the single implementation. A test with a
resolver double returning an impossible price proves the credit follows the wire;
reverting the payout to a ladder fails it.

Gated on OPENFUT_FIFA17_DISCARD_TABLE=1, default off: switching revalues the real
1991-item club 10.5x (1,820,400 -> 19,128,955 coins if wholly liquidated), up for
specials and DOWN for consumables, which the ladder overpaid 5.5x. That is an
operator's decision.

Staff decline to the ladder rather than pay 0: the client re-rates cardtypes
2/3/4/5/10 from its own DB and their rating is not imported. Deliberately not
guessed -- see the falsifier in the doc.

Verified on staging with the real club, both modes: flag off 1500 wire / 1500
paid; flag on 23760 wire / 23760 paid on an r99 rareflag-11 card (99*24000/100).
Consumables price from their catalog rating and agree with the client's own
computation. Adapter 244 lib tests, host 121 lib + 45 host_test, fmt and clippy
clean.
This commit is contained in:
funman300
2026-08-21 22:40:04 +00:00
parent 274838cc2e
commit 49b18dd4ac
10 changed files with 693 additions and 35 deletions
@@ -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 items the warning was aimed at, the server's rating and rare flag are
authoritative. 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` ### 3.7 `duplicateItemIdList`
CONFIRMED shape, INFERRED effect, never observed. Element deser `FUN_180138e10`, CONFIRMED shape, INFERRED effect, never observed. Element deser `FUN_180138e10`,
@@ -54,7 +54,7 @@ pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
match ident.kind_of(item) { match ident.kind_of(item) {
ContentKind::Player => match ident.resolve(item) { ContentKind::Player => match ident.resolve(item) {
Some(id) => { Some(id) => {
out.push(shape_item(item, id, ent)); out.push(shape_item(item, id, ent, ident.discard_value(item)));
stats.emitted += 1; stats.emitted += 1;
} }
None => stats.dropped_no_asset += 1, None => stats.dropped_no_asset += 1,
+380
View File
@@ -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));
}
}
}
+37 -4
View File
@@ -208,6 +208,25 @@ pub trait ItemIdentityResolver {
None 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 /// 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 /// 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 /// 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, pub excluded_non_player: usize,
} }
/// Quick-sell / discard value by rating tier (mirrors Core's quick-sell table; /// The ORIGINAL rating-only quick-sell ladder. **A placeholder, not
/// non-fatal display field). /// EA-authentic**: it is blind to both card type and `rareflag`, so it prices a
fn discard_value(rating: u8) -> i64 { /// 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 { match rating {
r if r >= 85 => 1500, r if r >= 85 => 1500,
r if r >= 80 => 900, r if r >= 80 => 900,
@@ -271,6 +298,7 @@ pub fn shape_item(
item: &CoreOwnedItem, item: &CoreOwnedItem,
id: Fifa17Identity, id: Fifa17Identity,
ent: &impl ReverseEntityResolver, ent: &impl ReverseEntityResolver,
discard_value: i64,
) -> Value { ) -> Value {
let asset = id.asset_id; let asset = id.asset_id;
let league_id = ent.league_id(&item.league).unwrap_or(0); let league_id = ent.league_id(&item.league).unwrap_or(0);
@@ -308,7 +336,7 @@ pub fn shape_item(
"untradeable": false, "untradeable": false,
"contract": 7, "contract": 7,
"fitness": 99, "fitness": 99,
"discardValue": discard_value(item.rating), "discardValue": discard_value,
}) })
} }
@@ -533,6 +561,7 @@ mod tests {
rareflag: 1, rareflag: 1,
}, },
&ent, &ent,
legacy_discard_value(86),
); );
assert_eq!(it["id"], 100000001, "wire instance id"); assert_eq!(it["id"], 100000001, "wire instance id");
assert_eq!(it["resourceId"], 20801); assert_eq!(it["resourceId"], 20801);
@@ -566,6 +595,7 @@ mod tests {
rareflag: 1, rareflag: 1,
}, },
&ent, &ent,
legacy_discard_value(84),
); );
let b = shape_item( let b = shape_item(
&item("oc-b", "fifa17_101490", 84, "ST"), &item("oc-b", "fifa17_101490", 84, "ST"),
@@ -576,6 +606,7 @@ mod tests {
rareflag: 1, rareflag: 1,
}, },
&ent, &ent,
legacy_discard_value(84),
); );
assert_eq!( assert_eq!(
a["resourceId"], b["resourceId"], a["resourceId"], b["resourceId"],
@@ -604,6 +635,7 @@ mod tests {
rareflag: 3, rareflag: 3,
}, },
&ent, &ent,
legacy_discard_value(92),
); );
assert_eq!( assert_eq!(
it["resourceId"], 117617092, it["resourceId"], 117617092,
@@ -708,6 +740,7 @@ mod tests {
rareflag: 1, rareflag: 1,
}, },
&ent, &ent,
legacy_discard_value(86),
); );
emitted.push(player["itemState"].as_str().unwrap().to_string()); emitted.push(player["itemState"].as_str().unwrap().to_string());
let staff = shape_staff_item( let staff = shape_staff_item(
+1
View File
@@ -9,6 +9,7 @@ pub mod club_response;
pub mod club_stats; pub mod club_stats;
pub mod consumables; pub mod consumables;
pub mod content_taxonomy; pub mod content_taxonomy;
pub mod discard;
pub mod economy; pub mod economy;
pub mod economy_policy; pub mod economy_policy;
pub mod entities; pub mod entities;
@@ -166,7 +166,7 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
.unwrap_or(0); .unwrap_or(0);
players.push(json!({ players.push(json!({
"index": index, "index": index,
"itemData": shape_item(item, id, ent), "itemData": shape_item(item, id, ent, ident.discard_value(item)),
"kitNumber": kit, "kitNumber": kit,
})); }));
} }
+86 -18
View File
@@ -162,7 +162,12 @@ fn shape_minted(deps: &StoreDeps<'_>, minted: &[Minted]) -> Vec<Value> {
.filter_map(|m| { .filter_map(|m| {
let item = core_owned(m); let item = core_owned(m);
let id = deps.assets.resolve(&item)?; 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() .collect()
} }
@@ -371,22 +376,13 @@ pub struct QuickSellDeps<'a> {
/// uses). Identity only; ownership is authorized by [`OwnedItemLookup`]. /// uses). Identity only; ownership is authorized by [`OwnedItemLookup`].
pub reverse: &'a dyn SquadWireResolver, pub reverse: &'a dyn SquadWireResolver,
pub items: &'a dyn OwnedItemLookup, pub items: &'a dyn OwnedItemLookup,
} /// 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
/// OPENFUT CURRENT quick-sell value by rating (PLACEHOLDER, not EA-authentic). /// construction the number the client displayed — there is no second ladder
/// Mirrors the on-wire `discardValue` that /// to drift. (The Python oracle had exactly that bug: an invented
/// `openfut_adapter_fifa17::fut::item` stamps, so the coins credited equal the /// 600/300/150/50 fallback in `fut_store.py:505` that disagreed with the
/// value the client displayed. (The Python oracle used a *different* invented /// wire `discardValue`.)
/// fallback — 600/300/150/50, `fut_store.py:505` — which disagreed with the wire pub assets: &'a dyn ItemIdentityResolver,
/// `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,
}
} }
/// Quick-sell every owned card in `wire_ids` (server-priced). Skips ids that do /// 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, Some(i) => i,
None => continue, // resolvable id, but not owned: never sold 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) => { Ok(balance) => {
last_balance = Some(balance); last_balance = Some(balance);
sold_ids.push(wire); 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<Fifa17Identity> {
None
}
}
static LADDER_ASSETS: LadderAssets = LadderAssets;
fn qs_deps<'a>( fn qs_deps<'a>(
econ: &'a RecEcon, econ: &'a RecEcon,
reverse: &'a FakeReverse, reverse: &'a FakeReverse,
@@ -999,6 +1008,7 @@ mod tests {
econ, econ,
reverse, reverse,
items, items,
assets: &LADDER_ASSETS,
} }
} }
@@ -1019,6 +1029,64 @@ mod tests {
assert_eq!(sold[0], ("c1".to_string(), 1500)); 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<Fifa17Identity> {
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] #[test]
fn quick_sell_body_form_and_alias_match_path_form() { fn quick_sell_body_form_and_alias_match_path_form() {
for req in [ for req in [
+143 -1
View File
@@ -50,7 +50,9 @@ use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Instant, SystemTime, UNIX_EPOCH}; 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::{ use openfut_adapter_fifa17::fut::club_response::{
shape_club_response_with_kits, ActiveKitAssignments, CoreOwnedItem, Fifa17ConsumableIdentity, shape_club_response_with_kits, ActiveKitAssignments, CoreOwnedItem, Fifa17ConsumableIdentity,
Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver, Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver,
@@ -60,6 +62,7 @@ use openfut_adapter_fifa17::fut::consumables::consumables_response;
use openfut_adapter_fifa17::fut::content_taxonomy::{ use openfut_adapter_fifa17::fut::content_taxonomy::{
consumable_families_for_category, consumable_family, position_group, ContentKind, PositionGroup, 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::entities::{Fifa17Entities, ReverseEntityResolver};
use openfut_adapter_fifa17::fut::item::CONSUMABLE_UNTRADEABLE; use openfut_adapter_fifa17::fut::item::CONSUMABLE_UNTRADEABLE;
use openfut_adapter_fifa17::fut::match_wire; 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<Fifa17KitIdentity> { fn resolve_kit(&self, item: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
let ident = self.catalog.lookup(&item.card_id)?; let ident = self.catalog.lookup(&item.card_id)?;
// The whole cardtype-7 club family shares this record: kit, stadium and // The whole cardtype-7 club family shares this record: kit, stadium and
@@ -3692,6 +3720,7 @@ impl Server {
econ: svc.econ.as_ref(), econ: svc.econ.as_ref(),
reverse: self.resolver.as_ref(), reverse: self.resolver.as_ref(),
items: &lookup, items: &lookup,
assets: self.resolver.as_ref(),
}; };
handle_quick_sell_path(id, &deps) handle_quick_sell_path(id, &deps)
} }
@@ -3703,6 +3732,7 @@ impl Server {
econ: svc.econ.as_ref(), econ: svc.econ.as_ref(),
reverse: self.resolver.as_ref(), reverse: self.resolver.as_ref(),
items: &lookup, items: &lookup,
assets: self.resolver.as_ref(),
}; };
handle_quick_sell_body(body, &deps) 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")) *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<bool> = 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<i64> {
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. /// A JSON response with an explicit status.
fn json_status(status: u16, v: &Value) -> WireResponse { fn json_status(status: u16, v: &Value) -> WireResponse {
let body = serde_json::to_vec(v).unwrap_or_default(); let body = serde_json::to_vec(v).unwrap_or_default();
@@ -5033,6 +5111,70 @@ mod tests {
use super::*; use super::*;
use crate::async_bridge::AsyncBridge; use crate::async_bridge::AsyncBridge;
/// A catalog identity carrying only the fields discard pricing reads.
fn priced_def(subtype: i64, rareflag: i64, rating: Option<u8>) -> 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 /// A configurable in-memory economy double: real balance/entitlements, or a
/// forced error to prove fail-closed behavior. /// forced error to prove fail-closed behavior.
struct FakeEconomy { struct FakeEconomy {
+1 -1
View File
@@ -284,7 +284,7 @@ pub fn resolve_market_list<E: ReverseEntityResolver>(
let identity = resolver.resolve(&owned); let identity = resolver.resolve(&owned);
let resource_id = identity.map(|id| id.resource_id as i64); let resource_id = identity.map(|id| id.resource_id as i64);
let item_json = identity 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()); .and_then(|card| serde_json::to_string(&card).ok());
Some(ResolvedListing { Some(ResolvedListing {
item_id, item_id,
@@ -226,16 +226,11 @@ fn set_balance(client: &HttpCoreClient, target: i64) {
assert_eq!(client.balance().unwrap(), target, "balance set"); assert_eq!(client.balance().unwrap(), target, "balance set");
} }
/// The on-wire quick-sell `discardValue` tiers (host `economy_store::quick_sell_value`), /// The on-wire quick-sell `discardValue`, taken from the ONE implementation the
/// replicated to assert the EXACT credit — the client-visible contract. /// server uses rather than replicated here: a local copy silently passes while
/// the real price changes underneath it.
fn qs_value(rating: u8) -> i64 { fn qs_value(rating: u8) -> i64 {
match rating { openfut_adapter_fifa17::fut::item::legacy_discard_value(rating)
r if r >= 85 => 1500,
r if r >= 80 => 900,
r if r >= 75 => 600,
r if r >= 65 => 300,
_ => 150,
}
} }
fn owns(client: &HttpCoreClient, core_id: &str) -> bool { fn owns(client: &HttpCoreClient, core_id: &str) -> bool {