//! FIFA 17 economy POLICY mappers (pure, game-specific). //! //! These translate FIFA 17 wire semantics into the generic amounts the host //! feeds to Core economy authority. They own NO state — Core owns balances and //! inventory; these are the FIFA-specific numbers/derivations. Pack prices come //! from the Store catalogue; the transfer-market fee is the FUT-era 5%. //! //! Match result mapping + reward-body shaping live in [`crate::fut::match_wire`]. use crate::fut::store_catalog::pack_by_id; /// The Store buy-now price for a pack id (`None` for unknown/owned-only packs, /// which are never purchasable). pub fn pack_price(pack_id: u64) -> Option { pack_by_id(pack_id) .filter(|p| !p.owned_only) .map(|p| p.price) } /// FIFA 17 transfer-market fee, in PERCENT of the gross sale price. /// /// FIFA17-HISTORICAL: 5% is the well-documented FUT transfer tax of the era. It /// was not recovered from our client binary — no `tax`/`netPrice`/`sellerProceeds` /// wire field exists (`docs/FIFA17_TRANSFER_MARKET_WIRE.md`), because the client /// is told only the GROSS price and the deduction is server-side. pub const TRANSFER_MARKET_FEE_PERCENT: i64 = 5; /// Fee withheld from a completed sale of `gross` coins. /// /// Integer arithmetic only — coin settlement never touches floating point, where /// `0.05` is not representable and a large price could round a coin into or out of /// existence. Widening to `i128` for the multiply makes overflow unreachable for /// any `i64` price, so no ceiling has to be assumed. /// /// ROUNDING, and it is a CHOICE that needs live confirmation: the fee is FLOORED, /// so the seller keeps the fractional coin. That is deliberate — it makes /// `fee + proceeds == gross` hold exactly for every input, which is the property /// the accounting invariant depends on. The discriminating case against the /// alternative (flooring the seller's 95% instead) is a gross of 150: this rule /// pays 143, the alternative 142. Nothing in the corpus settles which the real /// server did, so this MUST NOT be treated as confirmed FIFA behaviour. /// /// A negative gross is not a sale; it yields a zero fee rather than inventing a /// negative one, and `settle_sale` rejects the price itself. pub fn transfer_market_fee(gross: i64) -> i64 { if gross <= 0 { return 0; } ((gross as i128 * TRANSFER_MARKET_FEE_PERCENT as i128) / 100) as i64 } /// What the seller is credited for a completed sale of `gross` coins. /// /// Defined as `gross - fee` rather than as its own percentage, so the pair can /// never disagree about where a rounded coin went. pub fn seller_proceeds(gross: i64) -> i64 { gross.max(0) - transfer_market_fee(gross) } #[cfg(test)] mod tests { use super::*; #[test] fn pack_price_rejects_unknown_and_owned_only() { assert!(pack_price(1).is_some()); assert_eq!(pack_price(65534), None); // sentinel absent from catalogue assert_eq!(pack_price(70), None); // owned-only reward pack, not purchasable } /// Pins the rounding rule at every boundary the fee can turn over. If one of /// these ever changes, monetary behaviour changed — that must be deliberate. #[test] fn transfer_market_fee_is_floored_five_percent() { // (gross, expected fee, expected proceeds) let cases = [ (0i64, 0i64, 0i64), (1, 0, 1), // 0.05 -> 0 (19, 0, 19), // 0.95 -> 0, the last fee-free price (20, 1, 19), // exactly 1.0, the first price that pays (21, 1, 20), // 1.05 -> 1 (39, 1, 38), // 1.95 -> 1 (40, 2, 38), // exactly 2.0 (100, 5, 95), (101, 5, 96), // 5.05 -> 5 (119, 5, 114), // 5.95 -> 5, last price paying 5 (120, 6, 114), // exactly 6.0 (149, 7, 142), // 7.45 -> 7 (150, 7, 143), // 7.5 -> 7: THE discriminating case (alternative: 8/142) (151, 7, 144), // 7.55 -> 7 (a HALF-UP rule would pay 8 here too) (199, 9, 190), // 9.95 -> 9 (200, 10, 190), // exactly 10.0 (1_000, 50, 950), (15_000, 750, 14_250), // the canonical fixture (15_000_000, 750_000, 14_250_000), // FUT's practical price ceiling (i64::MAX, i64::MAX / 20, i64::MAX - i64::MAX / 20), // no overflow ]; for (gross, fee, proceeds) in cases { assert_eq!(transfer_market_fee(gross), fee, "fee for gross {gross}"); assert_eq!( seller_proceeds(gross), proceeds, "proceeds for gross {gross}" ); } } /// The property the whole settlement's accounting rests on: the fee and the /// seller's proceeds account for the gross EXACTLY, with no coin created or /// destroyed by rounding, at every price. #[test] fn fee_plus_proceeds_is_exactly_gross() { for gross in (0i64..2_000).chain([15_000, 999_999, 15_000_000, i64::MAX]) { assert_eq!( transfer_market_fee(gross) + seller_proceeds(gross), gross, "fee + proceeds != gross at {gross}" ); } } /// A non-sale must not invent a negative fee. #[test] fn negative_gross_yields_no_fee() { assert_eq!(transfer_market_fee(-1), 0); assert_eq!(transfer_market_fee(i64::MIN), 0); assert_eq!(seller_proceeds(-100), 0); } }