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
+125
View File
@@ -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]