feat(market): FIFA 5% transfer fee policy, host settle_sale capability, isolated staging harness
Core gains the generic settlement (gitlink 31ab4a6); the FIFA-specific parts live here. FEE (openfut-adapter-fifa17/src/fut/economy_policy.rs), beside pack_price and match_reward_total because 5% is a game policy constant and Core must stay game-neutral — Core only validates 0 <= fee <= gross and never computes a rate: TRANSFER_MARKET_FEE_PERCENT = 5 transfer_market_fee(gross) = floor(gross * 5 / 100), i128 intermediate seller_proceeds(gross) = gross - fee Integer only. Floating point is never used for coin settlement: 0.05 is not representable in binary and a f64 round trip can create or destroy a coin at large prices. Widening to i128 makes overflow unreachable for any i64 price, so no price ceiling has to be assumed. ROUNDING IS A CHOICE AND IT IS NOT CONFIRMED. The fee is floored, so the seller keeps the fractional coin, chosen because it makes fee + proceeds == gross hold exactly at every input — the property the accounting invariant rests on. The discriminating case against flooring the seller's 95% instead is a gross of 150: this rule pays 143, the alternative 142. Nothing in the corpus or the client binary settles which the real server did (the client is only ever told the gross; no tax/netPrice/sellerProceeds wire field exists). Pinned at 0/1/19/20/21/39/40/100/ 150/200/1_000/15_000/15_000_000/i64::MAX plus a fee+proceeds==gross sweep. HOST: CoreEconomy gains settle_sale + EconomySale/EconomySaleReceipt, implemented on HttpCoreClient as POST /economy/settle-sale. Request field names were checked against Core's actual SettleSaleRequest/SaleReceipt rather than assumed. Absent club ids are OMITTED from the body (not null), which is what Core's Outside/active-club defaults depend on, so a unit test pins that body shape. handle_market_buy is deliberately untouched: the synthetic buy path has no counterparty, so minting there is correct. HARNESS: scripts/settlement-staging.py, stdlib only, drives a REAL Core over real HTTP on an ephemeral port against a throwaway DB (production 8099/8199/18080 in a hard deny-list checked in three places), seeds the canonical two-party fixture, prints BEFORE/PURCHASE/AFTER with PASS-FAIL lines, cleans up in a finally. 31/31 pass. It found the rejection-precedence bug fixed in Core, and that Core's content preflight aborts startup on an owned card whose CardDefinitionId no pack defines. Gates: Core 194, adapter 217, host 127, harness 31/31, clippy clean, new code fmt-clean. Nothing deployed; no production process, port or database was touched.
This commit is contained in:
@@ -56,6 +56,46 @@ pub fn pack_price(pack_id: u64) -> Option<u64> {
|
||||
.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::*;
|
||||
@@ -84,4 +124,57 @@ mod tests {
|
||||
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),
|
||||
(150, 7, 143), // 7.5 -> 7: THE discriminating case (alternative: 8/142)
|
||||
(200, 10, 190),
|
||||
(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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user