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:
@@ -457,7 +457,7 @@ mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicI64, AtomicU32, Ordering};
|
||||
|
||||
use crate::{EconomyEntitlement, EconomyPurchase};
|
||||
use crate::{EconomyEntitlement, EconomyPurchase, EconomySale, EconomySaleReceipt};
|
||||
|
||||
// ── Recording economy double ────────────────────────────────────────────
|
||||
|
||||
@@ -588,6 +588,10 @@ mod tests {
|
||||
self.purchased.lock().push((cost, items.to_vec()));
|
||||
Ok(self.balance.fetch_sub(cost, Ordering::SeqCst) - cost)
|
||||
}
|
||||
fn settle_sale(&self, _sale: &EconomySale<'_>) -> Result<EconomySaleReceipt, CoreError> {
|
||||
// Sale settlement is not exercised by the Store/quick-sell paths.
|
||||
Err(CoreError::Status(501))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Identity / entity / lookup doubles ──────────────────────────────────
|
||||
|
||||
@@ -699,6 +699,41 @@ pub struct EconomyGrantItem {
|
||||
pub card_id: String,
|
||||
}
|
||||
|
||||
/// Terms of a completed market sale, for [`CoreEconomy::settle_sale`]. `gross`
|
||||
/// is what the buyer pays; `fee` is the market cut destroyed on settlement, so
|
||||
/// the seller nets `gross - fee`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EconomySale<'a> {
|
||||
pub item_id: &'a str,
|
||||
/// Club the caller believes owns the item; `None` -> Core's game-scoped
|
||||
/// active club. Core predicates the ownership move on this club, so a
|
||||
/// wrong (or already-settled) seller is rejected rather than silently
|
||||
/// re-run — this doubles as the replay guard.
|
||||
pub seller_club_id: Option<&'a str>,
|
||||
/// Acquiring club; `None` -> a counterparty outside the modelled economy:
|
||||
/// nobody is debited and the item is destroyed.
|
||||
pub buyer_club_id: Option<&'a str>,
|
||||
pub gross: i64,
|
||||
pub fee: i64,
|
||||
}
|
||||
|
||||
/// What Core did when settling a sale: the post-settlement balances of both
|
||||
/// sides (`buyer_balance` is `None` for an outside buyer) and how many squad
|
||||
/// slots the sold item vacated.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EconomySaleReceipt {
|
||||
pub item_id: String,
|
||||
pub card_id: String,
|
||||
pub seller_club_id: String,
|
||||
pub buyer_club_id: Option<String>,
|
||||
pub gross: i64,
|
||||
pub fee: i64,
|
||||
pub proceeds: i64,
|
||||
pub seller_balance: i64,
|
||||
pub buyer_balance: Option<i64>,
|
||||
pub squad_slots_freed: u64,
|
||||
}
|
||||
|
||||
/// The host's authoritative economy transport to Core. Every method is a single
|
||||
/// durable Core transaction. **Fail-closed:** on any transport/status/parse
|
||||
/// error the caller MUST surface a controlled error and NEVER fall back to
|
||||
@@ -721,6 +756,13 @@ pub trait CoreEconomy: Send + Sync {
|
||||
fn purchase_item(&self, cost: i64, item_id: &str, card_id: &str) -> Result<i64, CoreError>;
|
||||
/// Debit `cost` and mint several items atomically (open-on-buy Store packs).
|
||||
fn purchase_items(&self, cost: i64, items: &[EconomyGrantItem]) -> Result<i64, CoreError>;
|
||||
/// Settle a completed market sale in ONE Core transaction: evict the item
|
||||
/// from every squad, move ownership from the seller to `buyer_club_id`
|
||||
/// (or destroy it for an outside buyer), debit a club buyer `gross` and
|
||||
/// credit the seller `gross - fee`. Core rejects a sale whose named seller
|
||||
/// does not own the item, so a replayed settlement is refused, never
|
||||
/// double-paid.
|
||||
fn settle_sale(&self, sale: &EconomySale<'_>) -> Result<EconomySaleReceipt, CoreError>;
|
||||
}
|
||||
|
||||
impl HttpCoreClient {
|
||||
@@ -771,6 +813,12 @@ fn json_str(v: &Value, key: &str) -> Result<String, CoreError> {
|
||||
.ok_or_else(|| CoreError::Parse(format!("missing string field `{key}`")))
|
||||
}
|
||||
|
||||
fn json_u64(v: &Value, key: &str) -> Result<u64, CoreError> {
|
||||
v.get(key)
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| CoreError::Parse(format!("missing u64 field `{key}`")))
|
||||
}
|
||||
|
||||
impl CoreEconomy for HttpCoreClient {
|
||||
fn balance(&self) -> Result<i64, CoreError> {
|
||||
json_i64(&self.economy_get("balance")?, "balance")
|
||||
@@ -851,6 +899,48 @@ impl CoreEconomy for HttpCoreClient {
|
||||
)?;
|
||||
json_i64(&v, "balance")
|
||||
}
|
||||
|
||||
fn settle_sale(&self, sale: &EconomySale<'_>) -> Result<EconomySaleReceipt, CoreError> {
|
||||
let v = self.economy_post("settle-sale", &sale_request_body(sale))?;
|
||||
Ok(EconomySaleReceipt {
|
||||
item_id: json_str(&v, "item_id")?,
|
||||
card_id: json_str(&v, "card_id")?,
|
||||
seller_club_id: json_str(&v, "seller_club_id")?,
|
||||
// Absent or null both mean "no club buyer": the outside-sale case.
|
||||
buyer_club_id: v
|
||||
.get("buyer_club_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
gross: json_i64(&v, "gross")?,
|
||||
fee: json_i64(&v, "fee")?,
|
||||
proceeds: json_i64(&v, "proceeds")?,
|
||||
seller_balance: json_i64(&v, "seller_balance")?,
|
||||
buyer_balance: v.get("buyer_balance").and_then(Value::as_i64),
|
||||
squad_slots_freed: json_u64(&v, "squad_slots_freed")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize an [`EconomySale`] into the `POST /economy/settle-sale` JSON body.
|
||||
/// The two club fields are OMITTED when `None` — their absence is what selects
|
||||
/// Core's defaults (the game-scoped active club as seller, a counterparty
|
||||
/// outside the modelled economy as buyer).
|
||||
pub fn sale_request_body(sale: &EconomySale<'_>) -> Value {
|
||||
let mut body = json!({
|
||||
"item_id": sale.item_id,
|
||||
"gross": sale.gross,
|
||||
"fee": sale.fee,
|
||||
});
|
||||
let obj = body
|
||||
.as_object_mut()
|
||||
.expect("the literal above is a JSON object");
|
||||
if let Some(seller) = sale.seller_club_id {
|
||||
obj.insert("seller_club_id".into(), Value::from(seller));
|
||||
}
|
||||
if let Some(buyer) = sale.buyer_club_id {
|
||||
obj.insert("buyer_club_id".into(), Value::from(buyer));
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
/// Serialize a [`CoreReplaceRequest`] into the `PUT /squad/replace` JSON body.
|
||||
@@ -3405,6 +3495,41 @@ mod tests {
|
||||
}
|
||||
Ok(self.balance)
|
||||
}
|
||||
fn settle_sale(&self, _sale: &EconomySale<'_>) -> Result<EconomySaleReceipt, CoreError> {
|
||||
// Sale settlement is not exercised through this double.
|
||||
Err(CoreError::Status(501))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settle_sale_body_omits_absent_club_ids() {
|
||||
// Absent club ids are the wire signal for Core's defaults (active club
|
||||
// as seller, outside counterparty as buyer), so they must not appear.
|
||||
let outside = EconomySale {
|
||||
item_id: "item-x",
|
||||
seller_club_id: None,
|
||||
buyer_club_id: None,
|
||||
gross: 15_000,
|
||||
fee: 750,
|
||||
};
|
||||
let b = sale_request_body(&outside);
|
||||
assert_eq!(b["item_id"], "item-x");
|
||||
assert_eq!(b["gross"], 15_000);
|
||||
assert_eq!(b["fee"], 750);
|
||||
assert!(b.get("seller_club_id").is_none());
|
||||
assert!(b.get("buyer_club_id").is_none());
|
||||
|
||||
let between_clubs = EconomySale {
|
||||
seller_club_id: Some("club-seller"),
|
||||
buyer_club_id: Some("club-buyer"),
|
||||
..outside
|
||||
};
|
||||
let b = sale_request_body(&between_clubs);
|
||||
assert_eq!(b["seller_club_id"], "club-seller");
|
||||
assert_eq!(b["buyer_club_id"], "club-buyer");
|
||||
assert_eq!(b["item_id"], "item-x");
|
||||
assert_eq!(b["gross"], 15_000);
|
||||
assert_eq!(b["fee"], 750);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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