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:
@@ -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`,
|
||||
|
||||
@@ -54,7 +54,7 @@ pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
|
||||
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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -166,7 +166,7 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
||||
.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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -162,7 +162,12 @@ fn shape_minted(deps: &StoreDeps<'_>, minted: &[Minted]) -> Vec<Value> {
|
||||
.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<Fifa17Identity> {
|
||||
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<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]
|
||||
fn quick_sell_body_form_and_alias_match_path_form() {
|
||||
for req in [
|
||||
|
||||
@@ -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<Fifa17KitIdentity> {
|
||||
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<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.
|
||||
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<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
|
||||
/// forced error to prove fail-closed behavior.
|
||||
struct FakeEconomy {
|
||||
|
||||
@@ -284,7 +284,7 @@ pub fn resolve_market_list<E: ReverseEntityResolver>(
|
||||
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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user