9026220533
ROOT CAUSE, one line. openfut-import-fifa17 emitted `"overall": 0` for every non-player Core definition while `d.rating` already held EA's authoritative `value` -- and the very next block wrote that same number correctly to the adapter catalog. So the tier existed host-side but never reached Core: Core overall 0 -> /collection effective_overall 0 -> CoreOwnedItem.rating 0 -> tier_for_rating(0) = Bronze for a Gold (88) manager. That silent mis-grant is exactly what the 409 was protecting against, so the refusal was correct. The emitter now also writes `source_rating`, keeping `overall` at 0. Regenerating the production pack changes exactly 18 entries and exactly one field each (source_rating None -> value); same 1710 ids, same fingerprint 28c333f1e833338a. WHY value IS the tier source, and why the thresholds are the player ladder: LIVE_PROVEN, not inferred. The client re-rates staff from its own managercards/*coachcards/physiocards by carddbid and applies discard_level's 65/75 ladder; coach_probe/discard_probe agree 4/4 (manager value 88 -> level 3, coaches 66 -> level 2). The shipped coach tables corroborate: each family has exactly 3 tiers x 2 rarities, and only 65/75 splits them 2/2/2. Manager contracts stop refusing and now resolve the TARGET's tier from Core-owned state. Still fail-closed everywhere it matters: a coach or physio is `contract_target_not_a_manager` (only cardsubtypeid 4 is a manager), and a manager Core carries no source_rating for is `manager_tier_unknown` rather than a guessed tier. Core's own content_kind token is sent as target_kind, because Core calls the squad manager `manager` while the catalog classifies it `staff`+subtype 4. NOT implemented, unchanged: STORED_MANAGER_BONUS and MATCH_CONTRACT_DECREMENT.
1191 lines
47 KiB
Rust
1191 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,
|
|
// 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<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,
|
|
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<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);
|
|
}
|
|
}
|