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:
@@ -697,7 +697,9 @@ pub async fn handle_move_items(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{EconomyEntitlement, EconomyGrantItem, EconomyPurchase};
|
||||
use crate::{
|
||||
EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -727,9 +729,14 @@ mod tests {
|
||||
|
||||
// ---- CoreEconomy double that debits coins and counts purchase calls ----
|
||||
|
||||
/// A single coin pot stands in for the whole modelled economy: a buy-now
|
||||
/// debits it, and a club-to-club settlement debits the buyer then credits
|
||||
/// the seller out of the same pot, so the pot falls by exactly the fee —
|
||||
/// the coins the market destroys.
|
||||
struct CountingEconomy {
|
||||
balance: AtomicI64,
|
||||
purchase_calls: AtomicUsize,
|
||||
settle_calls: AtomicUsize,
|
||||
fail: bool,
|
||||
}
|
||||
impl CountingEconomy {
|
||||
@@ -737,6 +744,7 @@ mod tests {
|
||||
CountingEconomy {
|
||||
balance: AtomicI64::new(balance),
|
||||
purchase_calls: AtomicUsize::new(0),
|
||||
settle_calls: AtomicUsize::new(0),
|
||||
fail: false,
|
||||
}
|
||||
}
|
||||
@@ -744,9 +752,29 @@ mod tests {
|
||||
CountingEconomy {
|
||||
balance: AtomicI64::new(0),
|
||||
purchase_calls: AtomicUsize::new(0),
|
||||
settle_calls: AtomicUsize::new(0),
|
||||
fail: true,
|
||||
}
|
||||
}
|
||||
/// Atomic debit: reject (and do NOT debit) if it would go negative,
|
||||
/// mirroring Core's BadRequest(400) on insufficient funds.
|
||||
fn debit(&self, cost: i64) -> Result<i64, CoreError> {
|
||||
let mut cur = self.balance.load(Ordering::SeqCst);
|
||||
loop {
|
||||
if cur < cost {
|
||||
return Err(CoreError::Status(400));
|
||||
}
|
||||
match self.balance.compare_exchange(
|
||||
cur,
|
||||
cur - cost,
|
||||
Ordering::SeqCst,
|
||||
Ordering::SeqCst,
|
||||
) {
|
||||
Ok(_) => return Ok(cur - cost),
|
||||
Err(actual) => cur = actual,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl CoreEconomy for CountingEconomy {
|
||||
fn balance(&self) -> Result<i64, CoreError> {
|
||||
@@ -789,23 +817,7 @@ mod tests {
|
||||
if self.fail {
|
||||
return Err(CoreError::Status(500));
|
||||
}
|
||||
// Atomic debit: reject (and do NOT debit) if it would go negative,
|
||||
// mirroring Core's BadRequest(400) on insufficient funds.
|
||||
let mut cur = self.balance.load(Ordering::SeqCst);
|
||||
loop {
|
||||
if cur < cost {
|
||||
return Err(CoreError::Status(400));
|
||||
}
|
||||
match self.balance.compare_exchange(
|
||||
cur,
|
||||
cur - cost,
|
||||
Ordering::SeqCst,
|
||||
Ordering::SeqCst,
|
||||
) {
|
||||
Ok(_) => return Ok(cur - cost),
|
||||
Err(actual) => cur = actual,
|
||||
}
|
||||
}
|
||||
self.debit(cost)
|
||||
}
|
||||
fn purchase_items(
|
||||
&self,
|
||||
@@ -814,6 +826,32 @@ mod tests {
|
||||
) -> Result<i64, CoreError> {
|
||||
Err(CoreError::Status(500))
|
||||
}
|
||||
fn settle_sale(&self, sale: &EconomySale<'_>) -> Result<EconomySaleReceipt, CoreError> {
|
||||
self.settle_calls.fetch_add(1, Ordering::SeqCst);
|
||||
if self.fail {
|
||||
return Err(CoreError::Status(500));
|
||||
}
|
||||
// A club buyer pays out of the pot first (and can be too poor);
|
||||
// an outside buyer is not modelled, so nobody is debited.
|
||||
let buyer_balance = match sale.buyer_club_id {
|
||||
Some(_) => Some(self.debit(sale.gross)?),
|
||||
None => None,
|
||||
};
|
||||
let proceeds = sale.gross - sale.fee;
|
||||
let seller_balance = self.balance.fetch_add(proceeds, Ordering::SeqCst) + proceeds;
|
||||
Ok(EconomySaleReceipt {
|
||||
item_id: sale.item_id.to_string(),
|
||||
card_id: format!("card-of:{}", sale.item_id),
|
||||
seller_club_id: sale.seller_club_id.unwrap_or("active-club").to_string(),
|
||||
buyer_club_id: sale.buyer_club_id.map(str::to_string),
|
||||
gross: sale.gross,
|
||||
fee: sale.fee,
|
||||
proceeds,
|
||||
seller_balance,
|
||||
buyer_balance,
|
||||
squad_slots_freed: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- SquadWireResolver double -----------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user