//! FIFA 17 authoritative economy engine — coins + unopened-pack entitlements + //! owned inventory + stable item ids — with all-or-nothing transactional mutations. //! //! A faithful port of the Python oracle's `fut_store.Store` mutation primitives //! (`spend`/`grant_coins`/`quick_sell`/`record_match`/`grant_unopened_pack`/ //! `consume_unopened_pack`/`open_pack`/`add_items`) — the single-writer engine the //! eventual economy cutover needs. //! //! ## Status / why not wired (R3) //! //! This engine is deliberately **not wired** into the live host. The live FIFA 17 //! coin balance is one indivisible writer set — Store BUY (`spend`), pack-open, //! quick-sell, match rewards (`record_match`) AND the transfer-market buy-now //! (`spend`) all mutate the same `coins` + inventory in Python's `fut_profile.json`. //! No single route can become Rust-authoritative without migrating the whole //! cluster at once. The intended generic home (OpenFUT Core) is a preserved-dirty, //! frozen submodule, so the generic currency/inventory/transaction primitives can't //! land there yet. This engine + importer is therefore the coherent prerequisite: //! wiring it (and migrating every coin/inventory writer in one cut) is the R1 task. //! //! Generic concepts (balance/inventory/transaction) belong in Core once unfrozen; //! they are kept in the adapter meanwhile without distorting Core. //! //! ## Economy-parameter provenance //! //! Prices/odds/quick-sell values are current OpenFUT **PLACEHOLDER** economy, not //! EA-authentic. The mutation *invariants* (atomic debit, consume-once, no partial //! state, sentinel non-grantable) are the load-bearing contract this engine enforces. use serde_json::{json, Value}; use crate::fut::store_catalog::pack_by_id; use crate::fut::store_session::SENTINEL_PACK_ID; /// A transactional failure. On any `Err`, the engine is left UNCHANGED (no partial /// mutation) — the caller may retry or surface a wire error. #[derive(Debug, Clone, PartialEq, Eq)] pub enum EconomyError { /// Debit rejected: balance would go negative. InsufficientFunds { balance: i64, needed: i64 }, /// Pack id is not in the catalogue (includes the 65534 sentinel). UnknownPack(u64), /// No owned instance of this pack to consume. NotOwned(u64), /// The 65534 sentinel can never be bought/granted/opened. SentinelRejected, } /// The authoritative per-profile economy state. Mirrors the load-bearing /// `fut_profile.json` fields. Wrap in a profile-scoped `Mutex`/DB transaction at the /// host boundary (as the eventual Core repository will); the methods here are the /// atomic units. #[derive(Debug, Clone, PartialEq)] pub struct ProfileEconomy { coins: i64, unopened_pack_ids: Vec, items: Vec, next_item_id: i64, } impl ProfileEconomy { /// A fresh, empty economy (coins 0, no packs/items, ids from 1). pub fn new() -> Self { ProfileEconomy { coins: 0, unopened_pack_ids: Vec::new(), items: Vec::new(), next_item_id: 1, } } /// Import from a `fut_profile.json` object (the current authoritative store). /// Deterministic and idempotent on a fixture: reads coins, `unopenedPackIds`, /// `items`, `nextItemId`; missing fields take safe defaults. Never mutates the /// source. Production migration is NOT executed here. pub fn from_fut_profile(profile: &Value) -> Self { let coins = profile.get("coins").and_then(Value::as_i64).unwrap_or(0); let unopened_pack_ids = profile .get("unopenedPackIds") .and_then(Value::as_array) .map(|a| a.iter().filter_map(Value::as_u64).collect()) .unwrap_or_default(); let items = profile .get("items") .and_then(Value::as_array) .cloned() .unwrap_or_default(); // Continue item-id allocation past the highest existing id so re-import can // never mint a duplicate (Python persists nextItemId; we also floor by it). let max_item_id = items .iter() .filter_map(|it| it.get("id").and_then(Value::as_i64)) .max() .unwrap_or(0); let next_item_id = profile .get("nextItemId") .and_then(Value::as_i64) .unwrap_or(1) .max(max_item_id + 1); ProfileEconomy { coins, unopened_pack_ids, items, next_item_id, } } /// Write this economy back onto a `fut_profile.json` object (round-trip / export). /// Only the economy fields are touched; every other key is preserved. pub fn apply_to_fut_profile(&self, profile: &mut Value) { let obj = profile .as_object_mut() .expect("fut_profile is a JSON object"); obj.insert("coins".into(), json!(self.coins)); obj.insert("unopenedPackIds".into(), json!(self.unopened_pack_ids)); obj.insert("items".into(), Value::Array(self.items.clone())); obj.insert("nextItemId".into(), json!(self.next_item_id)); } // ── reads ──────────────────────────────────────────────────────────────── pub fn coins(&self) -> i64 { self.coins } pub fn unopened_pack_ids(&self) -> &[u64] { &self.unopened_pack_ids } pub fn next_item_id(&self) -> i64 { self.next_item_id } pub fn item_ids(&self) -> Vec { self.items .iter() .filter_map(|it| it.get("id").and_then(Value::as_i64)) .collect() } // ── generic primitives (atomic building blocks) ─────────────────────────── /// Credit coins (reward/quick-sell proceeds). Non-negative by type. pub fn credit(&mut self, amount: u64) { self.coins += amount as i64; } /// Debit coins. Fail-closed: insufficient balance leaves coins UNCHANGED. pub fn debit(&mut self, amount: u64) -> Result<(), EconomyError> { let needed = amount as i64; if self.coins < needed { return Err(EconomyError::InsufficientFunds { balance: self.coins, needed, }); } self.coins -= needed; Ok(()) } /// Grant one owned instance of a catalogue pack (reward/purchase entitlement). /// Rejects the 65534 sentinel and any non-catalogue id (mirrors /// `grant_unopened_pack`, which returns False for `pack_by_id() is None`). pub fn grant_pack(&mut self, pack_id: u64) -> Result<(), EconomyError> { if pack_id == SENTINEL_PACK_ID { return Err(EconomyError::SentinelRejected); } if pack_by_id(pack_id).is_none() { return Err(EconomyError::UnknownPack(pack_id)); } self.unopened_pack_ids.push(pack_id); Ok(()) } /// Consume exactly one owned instance of a pack. Fail-closed: not owned ⇒ Err, /// no mutation (consume-once; mirrors `consume_unopened_pack`). pub fn consume_pack(&mut self, pack_id: u64) -> Result<(), EconomyError> { match self.unopened_pack_ids.iter().position(|&p| p == pack_id) { Some(idx) => { self.unopened_pack_ids.remove(idx); Ok(()) } None => Err(EconomyError::NotOwned(pack_id)), } } /// Allocate the next stable, monotonic, unique item id. pub fn allocate_item_id(&mut self) -> i64 { let id = self.next_item_id; self.next_item_id += 1; id } /// Add an owned item, stamping a fresh unique id (overwriting any incoming id), /// and return the assigned id. pub fn add_item(&mut self, mut item: Value) -> i64 { let id = self.allocate_item_id(); if let Some(obj) = item.as_object_mut() { obj.insert("id".into(), json!(id)); } self.items.push(item); id } // ── composed atomic transactions (validate-then-mutate) ──────────────────── /// Store BUY as an entitlement: validate the pack + funds FIRST, then debit and /// grant the unopened pack — all-or-nothing. Rejects the sentinel. (The Python /// oracle opens on buy; the authoritative model separates buy→entitlement→open, /// which is why `open_pack` exists — a DIFFERENT-BY-DESIGN improvement over the /// reference, preserving the coin/entitlement invariants.) pub fn buy_pack(&mut self, pack_id: u64, price: u64) -> Result<(), EconomyError> { if pack_id == SENTINEL_PACK_ID { return Err(EconomyError::SentinelRejected); } if pack_by_id(pack_id).is_none() { return Err(EconomyError::UnknownPack(pack_id)); } if self.coins < price as i64 { return Err(EconomyError::InsufficientFunds { balance: self.coins, needed: price as i64, }); } // Both preconditions hold: commit. self.coins -= price as i64; self.unopened_pack_ids.push(pack_id); Ok(()) } /// Open an owned pack: consume exactly one entitlement FIRST (so a failed/absent /// entitlement grants nothing), then add the generated items with fresh ids. /// Returns the ids granted. Content generation/odds are the caller's concern and /// are current OpenFUT PLACEHOLDER. pub fn open_pack(&mut self, pack_id: u64, items: Vec) -> Result, EconomyError> { self.consume_pack(pack_id)?; // fail-closed: no items on a missing entitlement Ok(items.into_iter().map(|it| self.add_item(it)).collect()) } /// Quick-sell owned items by id: remove them and credit the caller-computed value /// total (values are FIFA17 policy, placeholder). Only ids actually owned are /// sold/credited (mirrors `quick_sell`). Returns `(sold_count, coins_credited)`. pub fn quick_sell(&mut self, ids: &[i64], value_of: impl Fn(&Value) -> u64) -> (u64, u64) { let want: std::collections::HashSet = ids.iter().copied().collect(); let mut credited = 0u64; let mut sold = 0u64; let mut kept = Vec::with_capacity(self.items.len()); for it in std::mem::take(&mut self.items) { let owned_id = it.get("id").and_then(Value::as_i64); if owned_id.is_some_and(|id| want.contains(&id)) { credited += value_of(&it); sold += 1; } else { kept.push(it); } } self.items = kept; if sold > 0 { self.credit(credited); } (sold, credited) } /// Transfer-market buy-now: debit the price FIRST, then acquire the item — the /// market shares the SAME authoritative coin balance (that is why it is inside /// this engine's boundary). All-or-nothing. pub fn market_buy_now(&mut self, price: u64, item: Value) -> Result { self.debit(price)?; Ok(self.add_item(item)) } /// Match/reward coin credit (`record_match` coin part; SBC/objective grants). pub fn grant_reward(&mut self, coins: u64) { self.credit(coins); } } impl Default for ProfileEconomy { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; fn eco(coins: i64, unopened: &[u64]) -> ProfileEconomy { ProfileEconomy { coins, unopened_pack_ids: unopened.to_vec(), items: Vec::new(), next_item_id: 1, } } #[test] fn debit_insufficient_is_fail_closed() { let mut e = eco(100, &[]); assert_eq!( e.debit(101), Err(EconomyError::InsufficientFunds { balance: 100, needed: 101 }) ); assert_eq!(e.coins(), 100, "no mutation on failure"); assert_eq!(e.debit(100), Ok(())); assert_eq!(e.coins(), 0); } #[test] fn buy_pack_is_atomic() { // Insufficient funds: neither coins nor entitlements change. let mut poor = eco(399, &[]); assert!(matches!( poor.buy_pack(1, 400), Err(EconomyError::InsufficientFunds { .. }) )); assert_eq!(poor.coins(), 399); assert!(poor.unopened_pack_ids().is_empty()); // Enough funds: debit + grant together. let mut ok = eco(1000, &[]); assert_eq!(ok.buy_pack(1, 400), Ok(())); assert_eq!(ok.coins(), 600); assert_eq!(ok.unopened_pack_ids(), &[1]); } #[test] fn sentinel_can_never_be_bought_or_granted() { let mut e = eco(1_000_000, &[]); assert_eq!( e.buy_pack(SENTINEL_PACK_ID, 0), Err(EconomyError::SentinelRejected) ); assert_eq!( e.grant_pack(SENTINEL_PACK_ID), Err(EconomyError::SentinelRejected) ); // Never openable either (no entitlement can exist for it). assert_eq!( e.open_pack(SENTINEL_PACK_ID, vec![json!({})]), Err(EconomyError::NotOwned(SENTINEL_PACK_ID)) ); assert_eq!(e.coins(), 1_000_000, "sentinel ops never mutate economy"); assert!(e.item_ids().is_empty()); } #[test] fn unknown_pack_rejected() { let mut e = eco(1_000_000, &[]); assert_eq!(e.buy_pack(999, 0), Err(EconomyError::UnknownPack(999))); assert_eq!(e.grant_pack(999), Err(EconomyError::UnknownPack(999))); } #[test] fn open_pack_consumes_exactly_once() { let mut e = eco(0, &[70]); let granted = e.open_pack(70, vec![json!({"rating": 84}), json!({"rating": 90})]); assert_eq!(granted, Ok(vec![1, 2])); assert!(e.unopened_pack_ids().is_empty(), "entitlement consumed"); assert_eq!(e.item_ids(), vec![1, 2], "unique ids assigned"); // Second open of the same (now-absent) entitlement grants nothing. assert_eq!( e.open_pack(70, vec![json!({})]), Err(EconomyError::NotOwned(70)) ); assert_eq!(e.item_ids(), vec![1, 2], "no items added on failed open"); } #[test] fn quick_sell_removes_owned_and_credits() { let mut e = eco(100, &[]); let a = e.add_item(json!({"rating": 84})); let b = e.add_item(json!({"rating": 90})); // sell only `a`; an unknown id is ignored (not owned). let (sold, credited) = e.quick_sell(&[a, 99999], |_| 300); assert_eq!((sold, credited), (1, 300)); assert_eq!(e.coins(), 400); assert_eq!(e.item_ids(), vec![b], "only the sold item removed"); } #[test] fn market_buy_now_is_atomic() { let mut poor = eco(50, &[]); assert!(matches!( poor.market_buy_now(100, json!({"rating": 84})), Err(EconomyError::InsufficientFunds { .. }) )); assert_eq!(poor.coins(), 50); assert!(poor.item_ids().is_empty(), "no item acquired on failed buy"); let mut ok = eco(500, &[]); let id = ok.market_buy_now(100, json!({"rating": 84})).unwrap(); assert_eq!(ok.coins(), 400); assert_eq!(ok.item_ids(), vec![id]); } #[test] fn item_ids_are_unique_and_monotonic() { let mut e = ProfileEconomy::new(); let ids: Vec = (0..5).map(|_| e.allocate_item_id()).collect(); assert_eq!(ids, vec![1, 2, 3, 4, 5]); assert_eq!(e.next_item_id(), 6); } #[test] fn fut_profile_import_export_round_trip() { let mut profile = json!({ "personaId": 33068179, "coins": 29876776, "unopenedPackIds": [70], "items": [{"id": 41, "rating": 84}, {"id": 42, "rating": 90}], "nextItemId": 43, "clubName": "OpenFUT" }); let e = ProfileEconomy::from_fut_profile(&profile); assert_eq!(e.coins(), 29876776); assert_eq!(e.unopened_pack_ids(), &[70]); assert_eq!(e.next_item_id(), 43, "past the highest existing id"); // Export preserves unrelated keys and reflects the economy exactly. e.apply_to_fut_profile(&mut profile); assert_eq!( profile["clubName"], json!("OpenFUT"), "unrelated key preserved" ); assert_eq!(profile["coins"], json!(29876776)); assert_eq!(profile["nextItemId"], json!(43)); // Re-import is stable. let e2 = ProfileEconomy::from_fut_profile(&profile); assert_eq!(e, e2); } #[test] fn import_floors_next_item_id_past_existing_ids() { // A stale/low nextItemId must never mint a duplicate id. let profile = json!({ "coins": 0, "items": [{"id": 500}], "nextItemId": 10 }); let mut e = ProfileEconomy::from_fut_profile(&profile); assert_eq!(e.next_item_id(), 501); assert_eq!(e.allocate_item_id(), 501); } }