//! 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/` 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::()) } 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, // A pack mints PLAYER cards, whose rating IS `overall`, so there is no // separate authored definition rating to carry. source_rating: None, core_content_kind: None, } } fn grants_of(minted: &[Minted]) -> Vec { 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, 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 = 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 { 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 = 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, entities: &Fifa17Entities, resolver: &(dyn ItemIdentityResolver + Send + Sync), ) -> WireResponse { let filtered: Vec = 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; } /// 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 { 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 = Vec::new(); let mut last_balance: Option = 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 = sold_ids.iter().map(|id| json!({ "id": id })).collect(); json_response(&json!({ "items": items, "totalCredits": total })) } /// `DELETE …/item/` — 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 = 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>, fail: bool, purchased: Mutex)>>, redeemed: Mutex)>>, sold: Mutex>, } 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 { if self.fail { Err(CoreError::Status(500)) } else { Ok(self.coins()) } } fn entitlements(&self) -> Result, CoreError> { if self.fail { return Err(CoreError::Status(500)); } Ok(self.entitlements.lock().clone()) } fn purchase_entitlement( &self, _cost: i64, definition_id: &str, ) -> Result { 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 { 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 { 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 { 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 { 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 { 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 { // Sale settlement is not exercised by the Store/quick-sell paths. Err(CoreError::Status(501)) } fn complete_match( &self, _m: &CoreMatchCompletion<'_>, ) -> Result { // Match completion is not exercised by the Store/quick-sell paths. Err(CoreError::Status(501)) } fn apply_consumable( &self, _req: &ConsumableApplyRequest<'_>, ) -> Result { // 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, // 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 { 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); impl SquadWireResolver for FakeReverse { fn owned_id_for_wire(&self, wire: i64) -> Option { self.0.get(&wire).cloned() } } struct FakeItems(HashMap); impl OwnedItemLookup for FakeItems { fn owned_item(&self, core_id: &str) -> Option { 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 { 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 { ::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 { 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::(&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::(&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::(&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::(&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::(&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::(&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, source_rating: None, core_content_kind: 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 { 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 { 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); } }