//! 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. Values are the //! current OpenFUT economy (match rewards are the Python oracle's //! `MATCH_COINS`/`MATCH_PARTICIPATION` at production defaults); pack prices come //! from the Store catalogue. use crate::fut::store_catalog::pack_by_id; /// Normalized match outcome for reward purposes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MatchResult { Win, Draw, Loss, } /// Participation award added to every match reward (oracle `MATCH_PARTICIPATION` /// default = 0). pub const MATCH_PARTICIPATION: i64 = 0; /// Per-result match coins (oracle `MATCH_COINS`: won 400 / draw 200 / loss 100). pub fn match_result_coins(result: MatchResult) -> i64 { match result { MatchResult::Win => 400, MatchResult::Draw => 200, MatchResult::Loss => 100, } } /// Total match reward = per-result coins + participation. pub fn match_reward_total(result: MatchResult) -> i64 { match_result_coins(result) + MATCH_PARTICIPATION } /// Derive the outcome from the match `endReason` enum (the oracle's primary /// signal, `_END_REASON`). Unknown/absent reasons default to `Draw`, matching /// the oracle's conservative default. Score-based derivation is a fallback the /// oracle also supports; the enum is authoritative when present. pub fn result_from_end_reason(end_reason: Option<&str>) -> MatchResult { match end_reason.unwrap_or("").to_ascii_uppercase().as_str() { "WIN" | "DNF_WIN" => MatchResult::Win, "LOSS" | "QUIT" | "DNF" | "DNF_LOSS" => MatchResult::Loss, // "DRAW", "DNF_DRAW", "NO_CONTEST", unknown -> draw. _ => MatchResult::Draw, } } /// 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 match_rewards_match_oracle() { assert_eq!(match_reward_total(MatchResult::Win), 400); assert_eq!(match_reward_total(MatchResult::Draw), 200); assert_eq!(match_reward_total(MatchResult::Loss), 100); } #[test] fn end_reason_maps_to_outcome() { assert_eq!(result_from_end_reason(Some("WIN")), MatchResult::Win); assert_eq!(result_from_end_reason(Some("dnf_win")), MatchResult::Win); assert_eq!(result_from_end_reason(Some("LOSS")), MatchResult::Loss); assert_eq!(result_from_end_reason(Some("QUIT")), MatchResult::Loss); assert_eq!(result_from_end_reason(Some("DRAW")), MatchResult::Draw); assert_eq!(result_from_end_reason(None), MatchResult::Draw); assert_eq!(result_from_end_reason(Some("weird")), MatchResult::Draw); } #[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); } }