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:
funman300
2026-08-18 00:51:37 +00:00
parent 0a007f4941
commit f6606accb3
8 changed files with 1206 additions and 22 deletions
+56 -18
View File
@@ -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 -----------------------------------------