Files
OpenFUT/openfut-adapter-fifa17/src/fut/economy_policy.rs
T
funman300 9ddd80993c feat(fifa17): route match completion to Core exactly-once economy
Adapter: new fut/match_wire.rs owns the FIFA17 match wire — endReason
enum -> canonical result token (win/draw/loss/dnf/no_contest), match-end
payload parse (goals from myMatchStats[0], omitted on DNF/QUIT), and the
reward-response projection (only reversed fields; never bidTokens/
qualifiedChampionEventId). Match logic removed from economy_policy.rs
(kept pack/fee); registered match_wire in fut/mod.rs.

Host: handle_match_end now applies the match to Core's authoritative
complete_match (POST /matches/complete) fail-closed — any Core error is a
503, never a Python fallback — and renders Core's authoritative coins.
Per-match identity from matchReportId or a body fingerprint keys Core's
durable idempotency. New CoreEconomy::complete_match transport +
CoreMatchCompletion/CoreMatchReceipt.

Tests: adapter endReason/parse/projection; host shaping, fail-closed,
malformed, identity dedupe; integration replay + rebased balance chains
(match now also grants XP/level-up/achievement coins).
2026-08-20 17:30:17 +00:00

130 lines
5.4 KiB
Rust

//! FIFA 17 economy POLICY mappers (pure, game-specific).
//!
//! These translate FIFA 17 wire semantics into the generic amounts the host
//! feeds to Core economy authority. They own NO state — Core owns balances and
//! inventory; these are the FIFA-specific numbers/derivations. Pack prices come
//! from the Store catalogue; the transfer-market fee is the FUT-era 5%.
//!
//! Match result mapping + reward-body shaping live in [`crate::fut::match_wire`].
use crate::fut::store_catalog::pack_by_id;
/// The Store buy-now price for a pack id (`None` for unknown/owned-only packs,
/// which are never purchasable).
pub fn pack_price(pack_id: u64) -> Option<u64> {
pack_by_id(pack_id)
.filter(|p| !p.owned_only)
.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::*;
#[test]
fn pack_price_rejects_unknown_and_owned_only() {
assert!(pack_price(1).is_some());
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),
(101, 5, 96), // 5.05 -> 5
(119, 5, 114), // 5.95 -> 5, last price paying 5
(120, 6, 114), // exactly 6.0
(149, 7, 142), // 7.45 -> 7
(150, 7, 143), // 7.5 -> 7: THE discriminating case (alternative: 8/142)
(151, 7, 144), // 7.55 -> 7 (a HALF-UP rule would pay 8 here too)
(199, 9, 190), // 9.95 -> 9
(200, 10, 190), // exactly 10.0
(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);
}
}