diff --git a/openfut-adapter-fifa17/src/fut/economy.rs b/openfut-adapter-fifa17/src/fut/economy.rs new file mode 100644 index 0000000..0d7af90 --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/economy.rs @@ -0,0 +1,442 @@ +//! 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); + } +} diff --git a/openfut-adapter-fifa17/src/fut/mod.rs b/openfut-adapter-fifa17/src/fut/mod.rs index 89309b7..d92f10e 100644 --- a/openfut-adapter-fifa17/src/fut/mod.rs +++ b/openfut-adapter-fifa17/src/fut/mod.rs @@ -6,6 +6,7 @@ //! socket — a Rust UTAS host wires it to Core later. pub mod catalog; pub mod club_response; +pub mod economy; pub mod entities; pub mod item; pub mod owned_query;