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:
funman300
2026-08-13 18:26:09 +00:00
parent 41494bd18f
commit 46e5f612c8
2 changed files with 443 additions and 0 deletions
+442
View File
@@ -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);
}
}
+1
View File
@@ -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;