9ddd80993c
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).
130 lines
5.4 KiB
Rust
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);
|
|
}
|
|
}
|