feat(fifa17): add authoritative economy engine + fut_profile importer
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.
This commit is contained in:
@@ -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<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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user