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:
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user