Files
OpenFUT/openfut-utas-host/src/economy_store.rs
T
funman300 6c97bc4e2b feat(fifa17): real player-contract consumable apply, replacing the probe
POST /ut/game/fifa17/item/resource/<rid> {"apply":[{"id":N}]} now performs a
durable atomic contract application instead of falling through to Python.

THE RULE. grant = fcc_contractcards[card][tier(TARGET.rating)], then
min(99, contract + grant). The column is keyed on the TARGET's tier, NOT the
card's own -- all 36 cells of EA's shipped table match the published FIFA 17
matrix, and staging discriminates the two readings outright: a bronze-RARE
card on a rating-89 player granted 3 (the gold column), where the card-level
reading predicts 15.

No client binary reads fcc_contractcards -- a string scan of every .exe/.dll
in the install finds it referenced nowhere, and CardsDLL reads only 14 fcc_
tables (fcc_discardcoins among them, which is why quick-sell prices locally).
Consumable effects are server-authoritative, so EA's shipped table is the only
non-invented source and the client renders whatever we persist and re-serve.

The host computes the grant, Core owns the mutation -- the same split
quick-sell already uses (host prices via discard_value, Core performs
sell_item), and what migration 0027 means by "Core defines NO per-category
formula".

FAILS CLOSED, never 200-and-do-nothing: manager contracts 409 because staff
ratings are unimported so the target tier is unknowable; every other family
409 as unproven; batch 400; unresolvable operand 404. Core's deterministic
refusals pass through with their own status instead of collapsing to 503,
which would tell the client to retry a request that can never succeed.

`contract: 7` stops being a hardcode in shape_item/shape_staff_item and
becomes the fallback for an instance Core tracks no contract for. `fitness: 99`
is the same class of hardcode and is deliberately untouched.

CLEAN CUTOVER: Route::ConsumableApplyProbe, its handler, apply_probe_enabled,
the OPENFUT_FIFA17_APPLY_PROBE gate and both probe scripts are deleted. A
handler no classifier can reach is this repo's recurring defect class, and the
new economy arm preempts the probe. fifa17-migration-rehearse.py also drove
the probe (spelled "apply probe", so an apply-probe grep missed it) and would
have eaten a card off the rehearsal profile; retargeted to a non-mutating
assertion.

Not implemented, on purpose: the stored-manager bonus (real mechanic, rule
appears in no shipped table -- guessing it would corrupt the proven part) and
contract decrement per match (nothing spends contracts yet).
2026-08-22 18:23:22 +00:00

1185 lines
47 KiB
Rust

//! FIFA 17 Store / owned-item **mutation** handlers, Core-backed and fail-closed.
//!
//! These implement the three economy WRITERS against Core economy authority:
//!
//! * [`handle_store_buy`] — `PUT …/store/transaction` (open-on-buy): debit +
//! mint the pack's cards atomically ([`CoreEconomy::purchase_items`]) and
//! reveal them in the `createPackResponse` envelope.
//! * [`handle_pack_open`] — `POST …/purchased`: open a coin pack (as above) or
//! redeem an owned reward pack's unopened entitlement
//! ([`CoreEconomy::redeem_entitlement`], consume-once + atomic add).
//! * [`handle_quick_sell`] — `DELETE …/item/<id>` and `POST /ut/delete/…/item`:
//! reverse-resolve the wire id, price the card server-side, and
//! [`CoreEconomy::sell_item`].
//!
//! Every path is **fail-closed**: a Core error yields a controlled FIFA-shaped
//! response (503 / 461), NEVER a Python fallback (which would be a second
//! writer). The pack contents come from the pure adapter generator
//! ([`generate_pack_contents`]); the host owns the impure parts — building the
//! candidate pool, minting Core instance ids, and shaping the wire item.
use std::collections::HashSet;
use rand::Rng;
use serde_json::{json, Value};
use openfut_adapter_fifa17::fut::club_response::shape_club_response;
use openfut_adapter_fifa17::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
use openfut_adapter_fifa17::fut::economy_policy::pack_price;
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
use openfut_adapter_fifa17::fut::item::{shape_item, CoreOwnedItem, ItemIdentityResolver};
use openfut_adapter_fifa17::fut::pack_content::{
generate_pack_contents, GeneratedCandidate, GeneratedCard,
};
use openfut_adapter_fifa17::fut::squad::SquadWireResolver;
use openfut_adapter_fifa17::fut::store_catalog::{
owned_pack_id_for_definition, pack_by_id, PackDef,
};
use crate::{
error_response, json_response, json_status, CoreAccess, CoreEconomy, CoreError,
EconomyGrantItem, WireResponse,
};
// ───────────────────────────── Dependencies ─────────────────────────────────
/// Records a freshly-minted owned item into the FIFA "purchased" pile (the
/// reveal screen source; the client polls `GET /purchased` for it, and moving a
/// card to the club clears it). Injected so the pure store handlers stay
/// unaware of the durable async pile store; the production impl bridges to it.
/// A missing sink (`None`) simply skips reveal-pile tracking (unit tests).
pub trait PurchasedPileSink {
fn record_purchased(&self, core_id: &str);
}
/// Dependencies for the Store buy / pack-open paths. Mirrors [`crate::ClubDeps`]:
/// the host owns the Core transport and the item resolver; the adapter supplies
/// the pure pack generator over an injected candidate `pool`.
pub struct StoreDeps<'a> {
/// Core economy authority (single durable writer).
pub econ: &'a dyn CoreEconomy,
/// Definition + instance identity resolver (allocates the numeric wire id).
pub assets: &'a (dyn ItemIdentityResolver + Send + Sync),
/// FIFA entity reverse-resolver used by the shared item shaper.
pub entities: &'a Fifa17Entities,
/// Candidates that resolve in BOTH the FIFA catalogue and Core content. The
/// host builds this so the generator stays pure. Empty → fail-closed.
pub pool: &'a [GeneratedCandidate],
/// Optional sink recording minted items into the "purchased" reveal pile.
pub purchased: Option<&'a dyn PurchasedPileSink>,
}
/// A card drawn by a pack, paired with the freshly-minted Core instance id it
/// will own once committed.
struct Minted {
core_id: String,
card: GeneratedCard,
}
/// Why a pack could not be opened. Distinguishes the client-visible 461 from the
/// fail-closed 503.
enum PackError {
/// Not enough coins; carries the (unchanged) balance to echo to the client.
Insufficient(i64),
/// A Core transport/status error, or an empty candidate pool. Fail-closed.
Closed,
}
/// Mint a globally-unique opaque Core owned-instance id from the injected RNG —
/// 128 bits of entropy, the same identity shape `openfut-import-fifa17` mints.
/// Production MUST seed the RNG from entropy so ids never collide; a seeded test
/// RNG keeps them reproducible within a run.
fn mint_instance_id(rng: &mut impl Rng) -> String {
format!("fifa17-owned-{:032x}", rng.gen::<u128>())
}
fn parse_body(body: &[u8]) -> Value {
serde_json::from_slice(body).unwrap_or_else(|_| json!({}))
}
fn core_owned(m: &Minted) -> CoreOwnedItem {
CoreOwnedItem {
owned_card_id: m.core_id.clone(),
card_id: m.card.card_id.clone(),
rating: m.card.rating,
position: m.card.position.clone(),
nation: m.card.nation.clone(),
league: m.card.league.clone(),
club: m.card.club.clone(),
attributes: m.card.attributes,
// Freshly minted by a pack/Store open, so Core tracks no contract for it
// yet: the shaper substitutes the pack-fresh default.
contract_matches: None,
}
}
fn grants_of(minted: &[Minted]) -> Vec<EconomyGrantItem> {
minted
.iter()
.map(|m| EconomyGrantItem {
item_id: m.core_id.clone(),
card_id: m.card.card_id.clone(),
})
.collect()
}
/// Draw the pack, mint fresh Core ids, then debit + mint into Core atomically.
/// Pre-checks the balance (matching the oracle's `spend`) and also maps Core's
/// post-commit insufficient-funds status (400) onto [`PackError::Insufficient`]
/// so a lost race still fails as 461, never a phantom buy.
fn draw_and_mint_coins(
deps: &StoreDeps<'_>,
pack: &PackDef,
price: i64,
rng: &mut impl Rng,
) -> Result<Vec<Minted>, PackError> {
let cards = generate_pack_contents(pack, rng, deps.pool);
if cards.is_empty() {
return Err(PackError::Closed); // empty pool / no content
}
let minted: Vec<Minted> = cards
.into_iter()
.map(|card| Minted {
core_id: mint_instance_id(rng),
card,
})
.collect();
let balance = deps.econ.balance().map_err(|_| PackError::Closed)?;
if balance < price {
return Err(PackError::Insufficient(balance));
}
match deps.econ.purchase_items(price, &grants_of(&minted)) {
Ok(_new_balance) => Ok(minted),
Err(CoreError::Status(400)) => Err(PackError::Insufficient(
deps.econ.balance().unwrap_or(balance),
)),
Err(_) => Err(PackError::Closed),
}
}
/// Shape each minted card onto the wire, allocating its numeric instance id. A
/// candidate that fails to resolve is dropped (the pool guarantees resolution,
/// so this is defence-in-depth, never the normal path).
fn shape_minted(deps: &StoreDeps<'_>, minted: &[Minted]) -> Vec<Value> {
minted
.iter()
.filter_map(|m| {
let item = core_owned(m);
let id = deps.assets.resolve(&item)?;
Some(shape_item(
&item,
id,
deps.entities,
deps.assets.discard_value(&item),
// A pack-pulled card is by definition pack-fresh, so it carries
// the default rather than a persisted count: Core has not yet
// stored this instance, let alone applied a contract to it.
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
))
})
.collect()
}
/// Allocate every minted card's numeric wire id (so a later `GET …/purchased`
/// or `/club` renders it) without building the full wire objects.
fn allocate_wire_ids(deps: &StoreDeps<'_>, minted: &[Minted]) {
for m in minted {
let _ = deps.assets.resolve(&core_owned(m));
}
}
/// Record each minted item into the "purchased" reveal pile, if a sink is wired.
fn record_purchased(deps: &StoreDeps<'_>, minted: &[Minted]) {
if let Some(sink) = deps.purchased {
for m in minted {
sink.record_purchased(&m.core_id);
}
}
}
fn insufficient_body(balance: i64) -> WireResponse {
json_status(
461,
&json!({ "reason": "insufficient_coins", "credits": balance }),
)
}
// ───────────────────────────── Store BUY ────────────────────────────────────
/// `PUT …/store/transaction` — the confirmed BUY (open-on-buy). A cancel, a
/// non-integer `packId`, or an unknown/owned-only pack is a no-op `200 {}`
/// (never a phantom buy). Otherwise debit `pack_price` + mint the pack's cards
/// atomically and reveal them. Insufficient coins → 461; any Core error → 503.
pub fn handle_store_buy(body: &[u8], deps: &StoreDeps<'_>, rng: &mut impl Rng) -> WireResponse {
let v = parse_body(body);
if v.get("state").and_then(Value::as_str) == Some("TRANSACTIONCANCEL") {
return json_response(&json!({}));
}
let pid = match v.get("packId").and_then(Value::as_u64) {
Some(p) => p,
None => return json_response(&json!({})), // packId absent / not an int
};
// `pack_price` is the authoritative buyable gate + price: it is `None` for an
// unknown pack AND for an owned-only reward pack (never coin-purchasable).
let price = match pack_price(pid) {
Some(p) => p as i64,
None => return json_response(&json!({})),
};
let pack = match pack_by_id(pid) {
Some(p) => p,
None => return json_response(&json!({})),
};
match draw_and_mint_coins(deps, pack, price, rng) {
Ok(minted) => {
let items = shape_minted(deps, &minted);
record_purchased(deps, &minted);
let count = items.len();
json_response(&json!({
"createPackResponse": {
"itemList": items,
"numberItems": count,
"purchasedPackId": pid,
"duplicateItemIdList": [],
}
}))
}
Err(PackError::Insufficient(balance)) => insufficient_body(balance),
Err(PackError::Closed) => error_response(503, "core_unavailable"),
}
}
// ───────────────────────────── Pack OPEN ────────────────────────────────────
/// The `POST …/purchased` success body (`FutPurchaseItemsServerResponse`). The
/// awarded cards are polled separately via `GET …/purchased`; this body only
/// carries the pack metadata.
fn pack_open_body(pid: u64, pack: &PackDef) -> WireResponse {
json_response(&json!({
"packId": pid,
"firstPartyStoreId": 0,
"groupName": "fifa17",
"productId": pid.to_string(),
"purchasePackType": match pack.category {
"gold" => "GOLD",
"silver" => "SILVER",
_ => "BRONZE",
},
}))
}
/// `POST …/purchased` — open a pack. For an owned-only reward pack, consume its
/// unopened entitlement (consume-once, atomic add). For a normal pack, debit +
/// mint like BUY. An unknown pack or an already-consumed reward is an honest
/// empty reveal (`200 {"itemData":[]}`); insufficient coins → 461; Core error →
/// 503. The entitlement survives a failed redeem (Core's add is all-or-nothing).
pub fn handle_pack_open(body: &[u8], deps: &StoreDeps<'_>, rng: &mut impl Rng) -> WireResponse {
let v = parse_body(body);
let pid = match v.get("packId").and_then(Value::as_u64) {
Some(p) => p,
None => return json_response(&json!({ "itemData": [] })),
};
let pack = match pack_by_id(pid) {
Some(p) => p,
None => return json_response(&json!({ "itemData": [] })),
};
if pack.owned_only {
let ents = match deps.econ.entitlements() {
Ok(e) => e,
Err(_) => return error_response(503, "core_unavailable"),
};
// The unopened pack instance is an entitlement whose definition id resolves
// to this owned-only pack id (a numeric id or a symbolic reward-pack name).
// Absent → already consumed / never granted: honest empty reveal.
let ent = match ents
.into_iter()
.find(|e| owned_pack_id_for_definition(&e.definition_id) == Some(pid))
{
Some(e) => e,
None => return json_response(&json!({ "itemData": [] })),
};
let cards = generate_pack_contents(pack, rng, deps.pool);
if cards.is_empty() {
return error_response(503, "core_unavailable"); // empty pool
}
let minted: Vec<Minted> = cards
.into_iter()
.map(|card| Minted {
core_id: mint_instance_id(rng),
card,
})
.collect();
match deps.econ.redeem_entitlement(&ent.id, &grants_of(&minted)) {
Ok(_definition_id) => {
allocate_wire_ids(deps, &minted);
record_purchased(deps, &minted);
pack_open_body(pid, pack)
}
// Entitlement stays (Core's consume+add is atomic): fail-closed.
Err(_) => error_response(503, "core_unavailable"),
}
} else {
match draw_and_mint_coins(deps, pack, pack.price as i64, rng) {
Ok(minted) => {
allocate_wire_ids(deps, &minted);
record_purchased(deps, &minted);
pack_open_body(pid, pack)
}
Err(PackError::Insufficient(balance)) => insufficient_body(balance),
Err(PackError::Closed) => error_response(503, "core_unavailable"),
}
}
}
/// Shape the `GET …/purchased` reveal: the owned items currently in the FIFA
/// "purchased" pile (`purchased_ids`), rendered with the SAME shaper `/club`
/// uses. Pure — the caller supplies the pile membership (from the durable pile
/// store) and Core's owned inventory; this filters + shapes. Presentation only:
/// it grants nothing, consumes no entitlement, allocates no id, moves no coins.
/// Repeated calls return the same reveal until a card is moved to the club.
pub fn shape_purchased_reveal(
owned: &[CoreOwnedItem],
purchased_ids: &HashSet<String>,
entities: &Fifa17Entities,
resolver: &(dyn ItemIdentityResolver + Send + Sync),
) -> WireResponse {
let filtered: Vec<CoreOwnedItem> = owned
.iter()
.filter(|it| purchased_ids.contains(&it.owned_card_id))
.cloned()
.collect();
let (body, _stats) = shape_club_response(&filtered, entities, resolver);
json_response(&body)
}
// ───────────────────────────── Quick-sell ───────────────────────────────────
/// Look up an owned item by its Core owned-instance id. Quick-sell needs the
/// card's rating to price it server-side; `None` = not owned (never sold).
pub trait OwnedItemLookup {
fn owned_item(&self, core_id: &str) -> Option<CoreOwnedItem>;
}
/// Production lookup backed by Core's owned inventory. O(n) per call — acceptable
/// at the quick-sell rate, and it reuses the same `/collection` boundary `/club`
/// reads, so ownership is exactly Core's authoritative set.
pub struct CoreItemLookup<'a> {
pub core: &'a dyn CoreAccess,
}
impl OwnedItemLookup for CoreItemLookup<'_> {
fn owned_item(&self, core_id: &str) -> Option<CoreOwnedItem> {
self.core
.all_owned()
.ok()?
.into_iter()
.find(|it| it.owned_card_id == core_id)
}
}
/// Dependencies for the quick-sell paths.
pub struct QuickSellDeps<'a> {
pub econ: &'a dyn CoreEconomy,
/// Wire id → Core owned-instance id (the same reverse resolver the squad PUT
/// uses). Identity only; ownership is authorized by [`OwnedItemLookup`].
pub reverse: &'a dyn SquadWireResolver,
pub items: &'a dyn OwnedItemLookup,
/// Prices the sale. This is the SAME resolver, and the same method, that
/// stamps `discardValue` onto the shaped card, so the coins credited are by
/// construction the number the client displayed — there is no second ladder
/// to drift. (The Python oracle had exactly that bug: an invented
/// 600/300/150/50 fallback in `fut_store.py:505` that disagreed with the
/// wire `discardValue`.)
pub assets: &'a dyn ItemIdentityResolver,
}
/// Quick-sell every owned card in `wire_ids` (server-priced). Skips ids that do
/// not reverse-resolve or are not owned (no phantom credit). `totalCredits` is
/// the absolute post-sale balance; `items` echoes only the accounted-for ids,
/// de-duplicated in request order. A Core error fails closed (503).
pub fn handle_quick_sell(wire_ids: &[i64], deps: &QuickSellDeps<'_>) -> WireResponse {
let mut seen = HashSet::new();
let mut sold_ids: Vec<i64> = Vec::new();
let mut last_balance: Option<i64> = None;
for &wire in wire_ids {
if wire <= 0 || !seen.insert(wire) {
continue;
}
let core_id = match deps.reverse.owned_id_for_wire(wire) {
Some(c) => c,
None => continue,
};
let item = match deps.items.owned_item(&core_id) {
Some(i) => i,
None => continue, // resolvable id, but not owned: never sold
};
match deps
.econ
.sell_item(&core_id, deps.assets.discard_value(&item))
{
Ok(balance) => {
last_balance = Some(balance);
sold_ids.push(wire);
}
Err(_) => return error_response(503, "core_unavailable"),
}
}
let total = match last_balance {
Some(b) => b,
None => match deps.econ.balance() {
Ok(b) => b,
Err(_) => return error_response(503, "core_unavailable"),
},
};
let items: Vec<Value> = sold_ids.iter().map(|id| json!({ "id": id })).collect();
json_response(&json!({ "items": items, "totalCredits": total }))
}
/// `DELETE …/item/<id>` — single-card quick-sell (the id is in the path).
pub fn handle_quick_sell_path(wire_id: i64, deps: &QuickSellDeps<'_>) -> WireResponse {
handle_quick_sell(&[wire_id], deps)
}
/// `POST /ut/delete/…/item` — bulk quick-sell. Accepts the retail
/// `{"itemData":[{"id":..}]}` form and the `{"itemId":[..]}` / `{"itemIds":[..]}`
/// aliases (oracle `quick_sell_route`).
pub fn handle_quick_sell_body(body: &[u8], deps: &QuickSellDeps<'_>) -> WireResponse {
let v = parse_body(body);
let mut ids: Vec<i64> = Vec::new();
if let Some(arr) = v.get("itemData").and_then(Value::as_array) {
for it in arr {
if let Some(id) = it.get("id").and_then(Value::as_i64) {
ids.push(id);
}
}
}
if ids.is_empty() {
for key in ["itemId", "itemIds"] {
if let Some(arr) = v.get(key).and_then(Value::as_array) {
ids.extend(arr.iter().filter_map(Value::as_i64));
}
}
}
handle_quick_sell(&ids, deps)
}
#[cfg(test)]
mod tests {
use super::*;
use openfut_adapter_fifa17::fut::item::Fifa17Identity;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::atomic::{AtomicI64, AtomicU32, Ordering};
use crate::{
ConsumableApplyOutcome, ConsumableApplyRequest, CoreMatchCompletion, CoreMatchReceipt,
EconomyEntitlement, EconomyPurchase, EconomySale, EconomySaleReceipt,
};
// ── Recording economy double ────────────────────────────────────────────
/// A Core economy double that records every mutation and moves a real
/// balance, so tests assert exact coin/entitlement/item deltas. Interior
/// mutability via atomics/mutex keeps it `Send + Sync` (the trait bound).
struct RecEcon {
balance: AtomicI64,
entitlements: Mutex<Vec<EconomyEntitlement>>,
fail: bool,
purchased: Mutex<Vec<(i64, Vec<EconomyGrantItem>)>>,
redeemed: Mutex<Vec<(String, Vec<EconomyGrantItem>)>>,
sold: Mutex<Vec<(String, i64)>>,
}
impl RecEcon {
fn new(balance: i64) -> Self {
RecEcon {
balance: AtomicI64::new(balance),
entitlements: Mutex::new(Vec::new()),
fail: false,
purchased: Mutex::new(Vec::new()),
redeemed: Mutex::new(Vec::new()),
sold: Mutex::new(Vec::new()),
}
}
fn with_entitlements(balance: i64, defs: &[&str]) -> Self {
let s = RecEcon::new(balance);
*s.entitlements.lock() = defs
.iter()
.enumerate()
.map(|(i, d)| EconomyEntitlement {
id: format!("e{i}"),
definition_id: (*d).into(),
})
.collect();
s
}
fn failing() -> Self {
let mut s = RecEcon::new(0);
s.fail = true;
s
}
fn coins(&self) -> i64 {
self.balance.load(Ordering::SeqCst)
}
}
impl CoreEconomy for RecEcon {
fn balance(&self) -> Result<i64, CoreError> {
if self.fail {
Err(CoreError::Status(500))
} else {
Ok(self.coins())
}
}
fn entitlements(&self) -> Result<Vec<EconomyEntitlement>, CoreError> {
if self.fail {
return Err(CoreError::Status(500));
}
Ok(self.entitlements.lock().clone())
}
fn purchase_entitlement(
&self,
_cost: i64,
definition_id: &str,
) -> Result<EconomyPurchase, CoreError> {
if self.fail {
return Err(CoreError::Status(500));
}
Ok(EconomyPurchase {
balance: self.coins(),
entitlement_id: format!("bought:{definition_id}"),
})
}
fn redeem_entitlement(
&self,
entitlement_id: &str,
items: &[EconomyGrantItem],
) -> Result<String, CoreError> {
if self.fail {
return Err(CoreError::Status(500));
}
let mut ents = self.entitlements.lock();
let idx = ents.iter().position(|e| e.id == entitlement_id);
let definition_id = match idx {
Some(i) => ents.remove(i).definition_id, // consume-once
None => return Err(CoreError::Status(404)),
};
self.redeemed
.lock()
.push((entitlement_id.to_string(), items.to_vec()));
Ok(definition_id)
}
fn sell_item(&self, item_id: &str, price: i64) -> Result<i64, CoreError> {
if self.fail {
return Err(CoreError::Status(500));
}
let new = self.balance.fetch_add(price, Ordering::SeqCst) + price;
self.sold.lock().push((item_id.to_string(), price));
Ok(new)
}
fn grant_reward(&self, amount: i64) -> Result<i64, CoreError> {
if self.fail {
return Err(CoreError::Status(500));
}
Ok(self.balance.fetch_add(amount, Ordering::SeqCst) + amount)
}
fn purchase_item(
&self,
cost: i64,
_item_id: &str,
_card_id: &str,
) -> Result<i64, CoreError> {
if self.fail {
return Err(CoreError::Status(500));
}
if self.coins() < cost {
return Err(CoreError::Status(400));
}
Ok(self.balance.fetch_sub(cost, Ordering::SeqCst) - cost)
}
fn purchase_items(&self, cost: i64, items: &[EconomyGrantItem]) -> Result<i64, CoreError> {
if self.fail {
return Err(CoreError::Status(500));
}
if self.coins() < cost {
return Err(CoreError::Status(400));
}
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))
}
fn complete_match(
&self,
_m: &CoreMatchCompletion<'_>,
) -> Result<CoreMatchReceipt, CoreError> {
// Match completion is not exercised by the Store/quick-sell paths.
Err(CoreError::Status(501))
}
fn apply_consumable(
&self,
_req: &ConsumableApplyRequest<'_>,
) -> Result<ConsumableApplyOutcome, CoreError> {
// Consumable apply is not exercised by the Store/quick-sell paths.
Err(CoreError::Status(501))
}
}
// ── Identity / entity / lookup doubles ──────────────────────────────────
/// Resolves every pool card to a real asset and hands out distinct, stable
/// instance ids. `Send + Sync` (atomic counter) for `StoreDeps::assets`.
struct FakeAssets {
by_card: HashMap<String, (u32, u32, i64)>, // card_id -> (asset, resource, rareflag)
next: AtomicU32,
}
impl FakeAssets {
fn for_pool(pool: &[GeneratedCandidate]) -> Self {
let by_card = pool
.iter()
.enumerate()
.map(|(i, c)| (c.card_id.clone(), (1000 + i as u32, 1000 + i as u32, 1i64)))
.collect();
FakeAssets {
by_card,
next: AtomicU32::new(0),
}
}
}
impl ItemIdentityResolver for FakeAssets {
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity> {
let (asset, resource, rareflag) = *self.by_card.get(&item.card_id)?;
let n = self.next.fetch_add(1, Ordering::SeqCst);
Some(Fifa17Identity {
item_id: 100_000_000 + n,
asset_id: asset,
resource_id: resource,
rareflag,
})
}
}
struct FakeReverse(HashMap<i64, String>);
impl SquadWireResolver for FakeReverse {
fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
self.0.get(&wire).cloned()
}
}
struct FakeItems(HashMap<String, CoreOwnedItem>);
impl OwnedItemLookup for FakeItems {
fn owned_item(&self, core_id: &str) -> Option<CoreOwnedItem> {
self.0.get(core_id).cloned()
}
}
// ── Fixtures ─────────────────────────────────────────────────────────────
fn cand(card: &str, rating: u8, gold: bool, special: bool) -> GeneratedCandidate {
GeneratedCandidate {
card_id: card.into(),
rating,
position: "ST".into(),
nation: "Brazil".into(),
league: "Premier League".into(),
club: "Arsenal".into(),
attributes: [rating; 6],
gold,
special,
}
}
fn pool() -> Vec<GeneratedCandidate> {
vec![
cand("g-1", 88, true, false),
cand("g-2", 84, true, false),
cand("g-3", 80, true, false),
cand("g-sp", 90, true, true),
cand("b-1", 64, false, false),
cand("b-2", 62, false, false),
]
}
fn rng(seed: u64) -> rand::rngs::StdRng {
<rand::rngs::StdRng as rand::SeedableRng>::seed_from_u64(seed)
}
fn store_deps<'a>(
econ: &'a RecEcon,
assets: &'a FakeAssets,
entities: &'a Fifa17Entities,
pool: &'a [GeneratedCandidate],
) -> StoreDeps<'a> {
StoreDeps {
econ,
assets,
entities,
pool,
purchased: None,
}
}
fn body(v: Value) -> Vec<u8> {
serde_json::to_vec(&v).unwrap()
}
// ── Store BUY ──────────────────────────────────────────────────────────
#[test]
fn buy_pack1_debits_mints_and_reveals_twelve_cards() {
let econ = RecEcon::new(10_000);
let pool = pool();
let assets = FakeAssets::for_pool(&pool);
let ent = Fifa17Entities::default();
let deps = store_deps(&econ, &assets, &ent, &pool);
let resp = handle_store_buy(&body(json!({ "packId": 1 })), &deps, &mut rng(1));
assert_eq!(resp.status, 200);
let b: Value = serde_json::from_slice(&resp.body).unwrap();
let cpr = &b["createPackResponse"];
assert_eq!(cpr["numberItems"], 12); // pack 1 count (10 bronze + 2 silver)
assert_eq!(cpr["itemList"].as_array().unwrap().len(), 12);
assert_eq!(cpr["purchasedPackId"], 1);
assert_eq!(cpr["duplicateItemIdList"], json!([]));
// Exactly one atomic debit of the pack price (400) minting 12 items.
assert_eq!(econ.coins(), 10_000 - 400);
let purchased = econ.purchased.lock();
assert_eq!(purchased.len(), 1);
assert_eq!(purchased[0].0, 400);
assert_eq!(purchased[0].1.len(), 12);
for g in &purchased[0].1 {
assert!(pool.iter().any(|c| c.card_id == g.card_id));
}
}
#[test]
fn buy_pack5_debits_gold_price_and_mints_twelve() {
let econ = RecEcon::new(10_000);
let pool = pool();
let assets = FakeAssets::for_pool(&pool);
let ent = Fifa17Entities::default();
let deps = store_deps(&econ, &assets, &ent, &pool);
let resp = handle_store_buy(&body(json!({ "packId": 5 })), &deps, &mut rng(2));
let b: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(b["createPackResponse"]["numberItems"], 12); // pack 5 count (10 gold + 2 silver)
assert_eq!(econ.coins(), 10_000 - 5000);
}
#[test]
fn buy_insufficient_returns_461_with_balance_and_no_debit() {
let econ = RecEcon::new(100);
let pool = pool();
let assets = FakeAssets::for_pool(&pool);
let ent = Fifa17Entities::default();
let deps = store_deps(&econ, &assets, &ent, &pool);
let resp = handle_store_buy(&body(json!({ "packId": 5 })), &deps, &mut rng(3));
assert_eq!(resp.status, 461);
let b: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(b["reason"], "insufficient_coins");
assert_eq!(b["credits"], 100);
assert_eq!(econ.coins(), 100); // untouched
assert!(econ.purchased.lock().is_empty());
}
#[test]
fn buy_unknown_pack_is_noop_200_empty() {
let econ = RecEcon::new(10_000);
let pool = pool();
let assets = FakeAssets::for_pool(&pool);
let ent = Fifa17Entities::default();
let deps = store_deps(&econ, &assets, &ent, &pool);
let resp = handle_store_buy(&body(json!({ "packId": 9999 })), &deps, &mut rng(4));
assert_eq!(resp.status, 200);
let b: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(b, json!({}));
assert_eq!(econ.coins(), 10_000);
}
#[test]
fn buy_sentinel_is_noop_200_empty() {
let econ = RecEcon::new(10_000);
let pool = pool();
let assets = FakeAssets::for_pool(&pool);
let ent = Fifa17Entities::default();
let deps = store_deps(&econ, &assets, &ent, &pool);
// 65534 is absent from the catalogue -> pack_price None -> no-op.
let resp = handle_store_buy(&body(json!({ "packId": 65534 })), &deps, &mut rng(5));
assert_eq!(resp.status, 200);
assert_eq!(
serde_json::from_slice::<Value>(&resp.body).unwrap(),
json!({})
);
}
#[test]
fn buy_owned_only_pack_is_noop_200_empty() {
let econ = RecEcon::new(10_000);
let pool = pool();
let assets = FakeAssets::for_pool(&pool);
let ent = Fifa17Entities::default();
let deps = store_deps(&econ, &assets, &ent, &pool);
// pack 70 is owned-only -> not coin-purchasable via BUY.
let resp = handle_store_buy(&body(json!({ "packId": 70 })), &deps, &mut rng(6));
assert_eq!(resp.status, 200);
assert_eq!(
serde_json::from_slice::<Value>(&resp.body).unwrap(),
json!({})
);
assert!(econ.purchased.lock().is_empty());
}
#[test]
fn buy_cancel_is_noop_200_empty() {
let econ = RecEcon::new(10_000);
let pool = pool();
let assets = FakeAssets::for_pool(&pool);
let ent = Fifa17Entities::default();
let deps = store_deps(&econ, &assets, &ent, &pool);
let resp = handle_store_buy(
&body(json!({ "packId": 5, "state": "TRANSACTIONCANCEL" })),
&deps,
&mut rng(7),
);
assert_eq!(resp.status, 200);
assert_eq!(
serde_json::from_slice::<Value>(&resp.body).unwrap(),
json!({})
);
assert_eq!(econ.coins(), 10_000);
}
#[test]
fn buy_non_integer_packid_is_noop() {
let econ = RecEcon::new(10_000);
let pool = pool();
let assets = FakeAssets::for_pool(&pool);
let ent = Fifa17Entities::default();
let deps = store_deps(&econ, &assets, &ent, &pool);
let resp = handle_store_buy(&body(json!({ "packId": "5" })), &deps, &mut rng(8));
assert_eq!(resp.status, 200);
assert_eq!(
serde_json::from_slice::<Value>(&resp.body).unwrap(),
json!({})
);
}
#[test]
fn buy_fails_closed_503_on_core_error() {
let econ = RecEcon::failing();
let pool = pool();
let assets = FakeAssets::for_pool(&pool);
let ent = Fifa17Entities::default();
let deps = store_deps(&econ, &assets, &ent, &pool);
let resp = handle_store_buy(&body(json!({ "packId": 1 })), &deps, &mut rng(9));
assert_eq!(resp.status, 503);
}
// ── Pack OPEN ────────────────────────────────────────────────────────────
#[test]
fn open_normal_pack_debits_and_returns_post_body() {
let econ = RecEcon::new(10_000);
let pool = pool();
let assets = FakeAssets::for_pool(&pool);
let ent = Fifa17Entities::default();
let deps = store_deps(&econ, &assets, &ent, &pool);
let resp = handle_pack_open(&body(json!({ "packId": 5 })), &deps, &mut rng(10));
assert_eq!(resp.status, 200);
let b: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(b["packId"], 5);
assert_eq!(b["productId"], "5");
assert_eq!(b["groupName"], "fifa17");
assert_eq!(b["firstPartyStoreId"], 0);
assert_eq!(b["purchasePackType"], "GOLD"); // pack 5 is gold
assert_eq!(econ.coins(), 10_000 - 5000);
assert_eq!(econ.purchased.lock()[0].1.len(), 12);
}
#[test]
fn open_owned_70_redeems_entitlement_without_debit() {
let econ = RecEcon::with_entitlements(4600, &["70"]);
let pool = pool();
let assets = FakeAssets::for_pool(&pool);
let ent = Fifa17Entities::default();
let deps = store_deps(&econ, &assets, &ent, &pool);
let resp = handle_pack_open(&body(json!({ "packId": 70 })), &deps, &mut rng(11));
assert_eq!(resp.status, 200);
let b: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(b["packId"], 70);
assert_eq!(b["productId"], "70");
assert_eq!(b["purchasePackType"], "GOLD");
// No coin debit; entitlement consumed; items added atomically.
assert_eq!(econ.coins(), 4600);
assert!(econ.purchased.lock().is_empty());
assert!(econ.entitlements.lock().is_empty()); // consumed
let redeemed = econ.redeemed.lock();
assert_eq!(redeemed.len(), 1);
assert_eq!(redeemed[0].0, "e0");
assert_eq!(redeemed[0].1.len(), 11); // pack 70 count
}
#[test]
fn open_symbolic_silver_reward_redeems_entitlement_without_debit() {
// A Core reward grant ("silver_pack") resolves to owned-only pack 72 and
// opens for free by consuming its entitlement — the SBC reward-pack fix.
let econ = RecEcon::with_entitlements(4600, &["silver_pack"]);
let pool = pool();
let assets = FakeAssets::for_pool(&pool);
let ent = Fifa17Entities::default();
let deps = store_deps(&econ, &assets, &ent, &pool);
let resp = handle_pack_open(&body(json!({ "packId": 72 })), &deps, &mut rng(12));
assert_eq!(resp.status, 200);
let b: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(b["packId"], 72);
assert_eq!(b["purchasePackType"], "SILVER");
assert_eq!(econ.coins(), 4600, "a reward pack opens for free");
assert!(
econ.entitlements.lock().is_empty(),
"the reward entitlement is consumed once"
);
let redeemed = econ.redeemed.lock();
assert_eq!(redeemed.len(), 1);
assert_eq!(redeemed[0].0, "e0");
assert_eq!(redeemed[0].1.len(), 12); // 1 bronze + 11 silver
}
#[test]
fn open_owned_70_twice_is_consume_once() {
let econ = RecEcon::with_entitlements(4600, &["70"]);
let pool = pool();
let assets = FakeAssets::for_pool(&pool);
let ent = Fifa17Entities::default();
let deps = store_deps(&econ, &assets, &ent, &pool);
let first = handle_pack_open(&body(json!({ "packId": 70 })), &deps, &mut rng(12));
assert_eq!(first.status, 200);
// The entitlement is gone: a second open is an honest empty reveal.
let second = handle_pack_open(&body(json!({ "packId": 70 })), &deps, &mut rng(13));
assert_eq!(second.status, 200);
assert_eq!(
serde_json::from_slice::<Value>(&second.body).unwrap(),
json!({ "itemData": [] })
);
assert_eq!(econ.redeemed.lock().len(), 1); // only the first
}
#[test]
fn open_unknown_pack_is_empty_reveal() {
let econ = RecEcon::new(10_000);
let pool = pool();
let assets = FakeAssets::for_pool(&pool);
let ent = Fifa17Entities::default();
let deps = store_deps(&econ, &assets, &ent, &pool);
let resp = handle_pack_open(&body(json!({ "packId": 424242 })), &deps, &mut rng(14));
assert_eq!(resp.status, 200);
assert_eq!(
serde_json::from_slice::<Value>(&resp.body).unwrap(),
json!({ "itemData": [] })
);
assert_eq!(econ.coins(), 10_000);
}
#[test]
fn open_insufficient_returns_461() {
let econ = RecEcon::new(100);
let pool = pool();
let assets = FakeAssets::for_pool(&pool);
let ent = Fifa17Entities::default();
let deps = store_deps(&econ, &assets, &ent, &pool);
let resp = handle_pack_open(&body(json!({ "packId": 5 })), &deps, &mut rng(15));
assert_eq!(resp.status, 461);
assert_eq!(econ.coins(), 100);
}
// ── Quick-sell ─────────────────────────────────────────────────────────
fn owned(core: &str, rating: u8) -> CoreOwnedItem {
CoreOwnedItem {
owned_card_id: core.into(),
card_id: "card-x".into(),
rating,
position: "ST".into(),
nation: "Brazil".into(),
league: "Premier League".into(),
club: "Arsenal".into(),
attributes: [rating; 6],
contract_matches: None,
}
}
/// Prices sales with the trait's DEFAULT (legacy ladder) implementation, so
/// these tests pin the deployed behaviour: no catalog, no table.
struct LadderAssets;
impl ItemIdentityResolver for LadderAssets {
fn resolve(&self, _item: &CoreOwnedItem) -> Option<Fifa17Identity> {
None
}
}
static LADDER_ASSETS: LadderAssets = LadderAssets;
fn qs_deps<'a>(
econ: &'a RecEcon,
reverse: &'a FakeReverse,
items: &'a FakeItems,
) -> QuickSellDeps<'a> {
QuickSellDeps {
econ,
reverse,
items,
assets: &LADDER_ASSETS,
}
}
#[test]
fn quick_sell_path_form_sells_and_credits() {
let econ = RecEcon::new(1000);
let reverse = FakeReverse(HashMap::from([(100_000_001, "c1".to_string())]));
let items = FakeItems(HashMap::from([("c1".to_string(), owned("c1", 88))]));
let deps = qs_deps(&econ, &reverse, &items);
let resp = handle_quick_sell_path(100_000_001, &deps);
assert_eq!(resp.status, 200);
let b: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(b["items"], json!([{ "id": 100_000_001i64 }]));
// rating 88 -> 1500 credited; balance 1000 + 1500.
assert_eq!(b["totalCredits"], 2500);
let sold = econ.sold.lock();
assert_eq!(sold.len(), 1);
assert_eq!(sold[0], ("c1".to_string(), 1500));
}
/// THE INVARIANT: the coins credited are whatever
/// [`ItemIdentityResolver::discard_value`] says — the SAME method, on the
/// SAME resolver, that stamps `discardValue` onto the shaped card. A second
/// pricing ladder living in this module is exactly the drift this pins
/// against, so the double returns a value no ladder could produce.
#[test]
fn the_sale_credits_whatever_priced_the_card_on_the_wire() {
struct OddPriced;
impl ItemIdentityResolver for OddPriced {
fn resolve(&self, _item: &CoreOwnedItem) -> Option<Fifa17Identity> {
None
}
fn discard_value(&self, _item: &CoreOwnedItem) -> i64 {
10_980
}
}
let econ = RecEcon::new(1000);
let reverse = FakeReverse(HashMap::from([(100_000_001, "c1".to_string())]));
// Rating 88 -> the legacy ladder would pay 1500. The resolver must win.
let items = FakeItems(HashMap::from([("c1".to_string(), owned("c1", 88))]));
let deps = QuickSellDeps {
econ: &econ,
reverse: &reverse,
items: &items,
assets: &OddPriced,
};
let resp = handle_quick_sell_path(100_000_001, &deps);
assert_eq!(resp.status, 200);
let b: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(b["totalCredits"], 11_980, "1000 + the card's own price");
assert_eq!(econ.sold.lock()[0], ("c1".to_string(), 10_980));
}
/// The trait default MUST stay the deployed ladder, so a resolver with no
/// catalog behind it prices exactly as it did before the table existed.
#[test]
fn the_default_price_is_still_the_legacy_ladder() {
for (rating, expected) in [
(94u8, 1500i64),
(88, 1500),
(82, 900),
(77, 600),
(66, 300),
(50, 150),
(0, 150),
] {
assert_eq!(
LADDER_ASSETS.discard_value(&owned("c", rating)),
expected,
"rating {rating}"
);
assert_eq!(
openfut_adapter_fifa17::fut::item::legacy_discard_value(rating),
expected
);
}
}
#[test]
fn quick_sell_body_form_and_alias_match_path_form() {
for req in [
json!({ "itemData": [{ "id": 100_000_001i64 }] }),
json!({ "itemId": [100_000_001i64] }),
json!({ "itemIds": [100_000_001i64] }),
] {
let econ = RecEcon::new(1000);
let reverse = FakeReverse(HashMap::from([(100_000_001, "c1".to_string())]));
let items = FakeItems(HashMap::from([("c1".to_string(), owned("c1", 88))]));
let deps = qs_deps(&econ, &reverse, &items);
let resp = handle_quick_sell_body(&body(req.clone()), &deps);
assert_eq!(resp.status, 200, "form {req} failed");
let b: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(b["items"], json!([{ "id": 100_000_001i64 }]));
assert_eq!(b["totalCredits"], 2500);
}
}
#[test]
fn quick_sell_unknown_id_credits_nothing() {
let econ = RecEcon::new(1000);
let reverse = FakeReverse(HashMap::new()); // resolves nothing
let items = FakeItems(HashMap::new());
let deps = qs_deps(&econ, &reverse, &items);
let resp = handle_quick_sell_path(555, &deps);
assert_eq!(resp.status, 200);
let b: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(b["items"], json!([]));
assert_eq!(b["totalCredits"], 1000); // unchanged
assert!(econ.sold.lock().is_empty());
}
#[test]
fn quick_sell_resolvable_but_not_owned_is_not_sold() {
let econ = RecEcon::new(1000);
let reverse = FakeReverse(HashMap::from([(100_000_009, "ghost".to_string())]));
let items = FakeItems(HashMap::new()); // "ghost" is not owned
let deps = qs_deps(&econ, &reverse, &items);
let resp = handle_quick_sell_path(100_000_009, &deps);
let b: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(b["items"], json!([]));
assert_eq!(b["totalCredits"], 1000);
assert!(econ.sold.lock().is_empty());
}
#[test]
fn quick_sell_bulk_dedups_and_totals_final_balance() {
let econ = RecEcon::new(1000);
let reverse = FakeReverse(HashMap::from([
(100_000_001, "c1".to_string()),
(100_000_002, "c2".to_string()),
]));
let items = FakeItems(HashMap::from([
("c1".to_string(), owned("c1", 88)), // 1500
("c2".to_string(), owned("c2", 70)), // 300
]));
let deps = qs_deps(&econ, &reverse, &items);
// c1 appears twice (dedup) plus an unknown id (skipped).
let resp = handle_quick_sell(&[100_000_001, 100_000_001, 100_000_002, 42], &deps);
let b: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(
b["items"],
json!([{ "id": 100_000_001i64 }, { "id": 100_000_002i64 }])
);
assert_eq!(b["totalCredits"], 1000 + 1500 + 300);
assert_eq!(econ.sold.lock().len(), 2);
}
#[test]
fn quick_sell_fails_closed_503_on_core_error() {
let econ = RecEcon::failing();
let reverse = FakeReverse(HashMap::from([(100_000_001, "c1".to_string())]));
let items = FakeItems(HashMap::from([("c1".to_string(), owned("c1", 88))]));
let deps = qs_deps(&econ, &reverse, &items);
let resp = handle_quick_sell_path(100_000_001, &deps);
assert_eq!(resp.status, 503);
}
}