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
+86 -18
View File
@@ -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 [
+143 -1
View File
@@ -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 {
+1 -1
View File
@@ -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,