46e5f612c8
Adds openfut-adapter-fifa17 fut::economy — the single-writer FIFA 17 economy engine the eventual cluster cutover needs: coins + unopened-pack entitlements + owned inventory + stable item ids, with all-or-nothing transactional mutations faithfully ported from the Python oracle's fut_store.Store primitives. Atomic ops: debit (fail-closed), credit, grant_pack/consume_pack (consume-once), allocate_item_id (unique/monotonic), add_item, and composed transactions buy_pack, open_pack, quick_sell, market_buy_now, grant_reward. Fail-closed everywhere; the 65534 sentinel can never be bought/granted/opened (defense at the grant primitive, mirroring grant_unopened_pack rejecting non-catalogue ids). from_fut_profile importer round-trips coins/unopenedPackIds/items/nextItemId and floors nextItemId past the highest existing id so re-import cannot mint a duplicate. 10 unit tests (atomicity, sentinel safety, consume-once, quick-sell, item-id uniqueness, import round-trip). NOT wired (R3): the live coin balance is one indivisible writer set spanning Store BUY, pack-open, quick-sell, match rewards AND the transfer market, all in fut_profile.json; and the generic home (OpenFUT Core) is a preserved-dirty/frozen submodule. So a safe single-writer cutover cannot be wired yet — this engine + importer is the coherent prerequisite. No dual-write introduced. No deployment.
443 lines
17 KiB
Rust
443 lines
17 KiB
Rust
//! 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<u64>,
|
|
items: Vec<Value>,
|
|
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<i64> {
|
|
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<Value>) -> Result<Vec<i64>, 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<i64> = 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<i64, EconomyError> {
|
|
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<i64> = (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);
|
|
}
|
|
}
|