feat(fifa17): host Core economy client + credits reader vertical

Add CoreEconomy transport (trait + HttpCoreClient impl over Core /economy/*):
balance, entitlements, purchase_entitlement, redeem_entitlement, sell_item,
grant_reward, purchase_item. Fail-closed by contract: any transport/status/parse
error surfaces a controlled error and NEVER falls back to Python (a fallback
would be a second writer).

Add the credits reader vertical: build_credits_body (byte-shape-identical to the
Python oracle: credits + currencies[].funds/finalFunds + optional
unopenedPacks.recoveredPacks) and handle_credits (coins = Core balance,
recoveredPacks = Core entitlement count; 503 fail-closed on Core error). Not yet
classifier-routed: the coins cluster flips as one coherent barrier once every
writer+reader moves together and Core is seeded. FakeEconomy double + 3 tests
(oracle shape, Core-backed read, fail-closed).
This commit is contained in:
OpenFUT Agent
2026-08-13 19:14:13 +00:00
parent d7c5307045
commit d240a61157
+314
View File
@@ -350,6 +350,171 @@ impl CoreAccess for HttpCoreClient {
}
}
// ───────────────────────────── Core economy boundary ────────────────────────
/// One unconsumed entitlement Core reports for the active club.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EconomyEntitlement {
pub id: String,
pub definition_id: String,
}
/// Outcome of a purchase: post-debit balance + the new entitlement id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EconomyPurchase {
pub balance: i64,
pub entitlement_id: String,
}
/// An item to place into inventory on entitlement redemption. `item_id` is the
/// caller-minted opaque Core instance id (the adapter maps it to/from the FIFA
/// numeric wire id via the identity store); `card_id` is the definition ref.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EconomyGrantItem {
pub item_id: String,
pub card_id: String,
}
/// The host's authoritative economy transport to Core. Every method is a single
/// durable Core transaction. **Fail-closed:** on any transport/status/parse
/// error the caller MUST surface a controlled error and NEVER fall back to
/// Python — a Python fallback would reintroduce a second writer.
pub trait CoreEconomy: Send + Sync {
fn balance(&self) -> Result<i64, CoreError>;
fn entitlements(&self) -> Result<Vec<EconomyEntitlement>, CoreError>;
fn purchase_entitlement(
&self,
cost: i64,
definition_id: &str,
) -> Result<EconomyPurchase, CoreError>;
fn redeem_entitlement(
&self,
entitlement_id: &str,
items: &[EconomyGrantItem],
) -> Result<String, CoreError>;
fn sell_item(&self, item_id: &str, price: i64) -> Result<i64, CoreError>;
fn grant_reward(&self, amount: i64) -> Result<i64, CoreError>;
fn purchase_item(&self, cost: i64, item_id: &str, card_id: &str) -> Result<i64, CoreError>;
}
impl HttpCoreClient {
fn economy_url(&self, tail: &str) -> String {
format!("{}/economy/{}", self.base_url, tail)
}
fn economy_get(&self, tail: &str) -> Result<Value, CoreError> {
let resp = self
.client
.get(self.economy_url(tail))
.header("X-OpenFUT-Game", &self.game)
.send()
.map_err(|e| CoreError::Http(e.to_string()))?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
return Err(CoreError::Status(status));
}
resp.json().map_err(|e| CoreError::Parse(e.to_string()))
}
fn economy_post(&self, tail: &str, body: &Value) -> Result<Value, CoreError> {
let resp = self
.client
.post(self.economy_url(tail))
.header("X-OpenFUT-Game", &self.game)
.json(body)
.send()
.map_err(|e| CoreError::Http(e.to_string()))?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
return Err(CoreError::Status(status));
}
resp.json().map_err(|e| CoreError::Parse(e.to_string()))
}
}
fn json_i64(v: &Value, key: &str) -> Result<i64, CoreError> {
v.get(key)
.and_then(Value::as_i64)
.ok_or_else(|| CoreError::Parse(format!("missing i64 field `{key}`")))
}
fn json_str(v: &Value, key: &str) -> Result<String, CoreError> {
v.get(key)
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| CoreError::Parse(format!("missing string field `{key}`")))
}
impl CoreEconomy for HttpCoreClient {
fn balance(&self) -> Result<i64, CoreError> {
json_i64(&self.economy_get("balance")?, "balance")
}
fn entitlements(&self) -> Result<Vec<EconomyEntitlement>, CoreError> {
let v = self.economy_get("entitlements")?;
let arr = v
.as_array()
.ok_or_else(|| CoreError::Parse("entitlements not an array".into()))?;
arr.iter()
.map(|e| {
Ok(EconomyEntitlement {
id: json_str(e, "id")?,
definition_id: json_str(e, "definition_id")?,
})
})
.collect()
}
fn purchase_entitlement(
&self,
cost: i64,
definition_id: &str,
) -> Result<EconomyPurchase, CoreError> {
let v = self.economy_post(
"purchase-entitlement",
&json!({ "cost": cost, "definition_id": definition_id }),
)?;
Ok(EconomyPurchase {
balance: json_i64(&v, "balance")?,
entitlement_id: json_str(&v, "entitlement_id")?,
})
}
fn redeem_entitlement(
&self,
entitlement_id: &str,
items: &[EconomyGrantItem],
) -> Result<String, CoreError> {
let items_json: Vec<Value> = items
.iter()
.map(|i| json!({ "item_id": i.item_id, "card_id": i.card_id }))
.collect();
let v = self.economy_post(
"redeem-entitlement",
&json!({ "entitlement_id": entitlement_id, "items": items_json }),
)?;
json_str(&v, "definition_id")
}
fn sell_item(&self, item_id: &str, price: i64) -> Result<i64, CoreError> {
let v = self.economy_post("sell-item", &json!({ "item_id": item_id, "price": price }))?;
json_i64(&v, "balance")
}
fn grant_reward(&self, amount: i64) -> Result<i64, CoreError> {
let v = self.economy_post("grant-reward", &json!({ "amount": amount }))?;
json_i64(&v, "balance")
}
fn purchase_item(&self, cost: i64, item_id: &str, card_id: &str) -> Result<i64, CoreError> {
let v = self.economy_post(
"purchase-item",
&json!({ "cost": cost, "item_id": item_id, "card_id": card_id }),
)?;
json_i64(&v, "balance")
}
}
/// Serialize a [`CoreReplaceRequest`] into the `PUT /squad/replace` JSON body.
pub fn replace_request_body(req: &CoreReplaceRequest) -> Value {
let slots: Vec<Value> = req
@@ -1185,6 +1350,46 @@ pub fn handle_user_mass_info(
(resp, log)
}
// ─────────────── Rust economy handlers (Core-backed authority) ───────────────
//
// These implement the FIFA17 economy routes against Core economy authority.
// They are Core-backed and fail-closed: a Core error yields a controlled FIFA-
// compatible response and NEVER a Python fallback (which would be a second
// writer). They are wired into `classify` only as one coherent barrier once the
// whole coins cluster (writers + readers) flips together and Core is seeded from
// the profile — a partial flip would desync the client's coin counter.
/// Build the `GET /user/credits` body — byte-shape-identical to the Python
/// oracle (`credits` + `currencies[].funds/finalFunds`, optional
/// `unopenedPacks.recoveredPacks`). The hub coin counter binds to
/// `currencies[0].funds`, not `credits`.
pub fn build_credits_body(coins: i64, unopened_count: usize) -> Value {
let mut body = json!({
"credits": coins,
"currencies": [
{"name": "coins", "funds": coins, "finalFunds": coins},
{"name": "points", "funds": 0, "finalFunds": 0},
],
});
if unopened_count > 0 {
body.as_object_mut().unwrap().insert(
"unopenedPacks".into(),
json!({ "preOrderPacks": 0, "recoveredPacks": unopened_count }),
);
}
body
}
/// `GET /user/credits` from Core authority: coins = Core balance, recoveredPacks
/// = Core unconsumed entitlement count. Fail-closed on any Core error (503, no
/// Python fallback, no fabricated balance).
pub fn handle_credits(econ: &dyn CoreEconomy) -> WireResponse {
match (econ.balance(), econ.entitlements()) {
(Ok(coins), Ok(ents)) => json_response(&build_credits_body(coins, ents.len())),
_ => error_response(503, "core_unavailable"),
}
}
// ───────────────────────────── HTTP wire types ──────────────────────────────
/// A response ready to write: status, headers, body.
@@ -1816,6 +2021,115 @@ fn write_response<W: Write>(w: &mut W, resp: &WireResponse) -> std::io::Result<(
mod tests {
use super::*;
/// A configurable in-memory economy double: real balance/entitlements, or a
/// forced error to prove fail-closed behavior.
struct FakeEconomy {
balance: i64,
entitlements: Vec<EconomyEntitlement>,
fail: bool,
}
impl FakeEconomy {
fn ok(balance: i64, ents: usize) -> Self {
FakeEconomy {
balance,
entitlements: (0..ents)
.map(|i| EconomyEntitlement {
id: format!("e{i}"),
definition_id: "pack".into(),
})
.collect(),
fail: false,
}
}
fn failing() -> Self {
FakeEconomy {
balance: 0,
entitlements: vec![],
fail: true,
}
}
}
impl CoreEconomy for FakeEconomy {
fn balance(&self) -> Result<i64, CoreError> {
if self.fail {
Err(CoreError::Status(500))
} else {
Ok(self.balance)
}
}
fn entitlements(&self) -> Result<Vec<EconomyEntitlement>, CoreError> {
if self.fail {
Err(CoreError::Status(500))
} else {
Ok(self.entitlements.clone())
}
}
fn purchase_entitlement(
&self,
_cost: i64,
definition_id: &str,
) -> Result<EconomyPurchase, CoreError> {
Ok(EconomyPurchase {
balance: self.balance,
entitlement_id: format!("bought:{definition_id}"),
})
}
fn redeem_entitlement(
&self,
_entitlement_id: &str,
_items: &[EconomyGrantItem],
) -> Result<String, CoreError> {
Ok("pack".into())
}
fn sell_item(&self, _item_id: &str, _price: i64) -> Result<i64, CoreError> {
Ok(self.balance)
}
fn grant_reward(&self, _amount: i64) -> Result<i64, CoreError> {
Ok(self.balance)
}
fn purchase_item(
&self,
_cost: i64,
_item_id: &str,
_card_id: &str,
) -> Result<i64, CoreError> {
Ok(self.balance)
}
}
#[test]
fn credits_body_matches_oracle_shape() {
let b = build_credits_body(29_876_776, 0);
assert_eq!(b["credits"], 29_876_776);
assert_eq!(b["currencies"][0]["name"], "coins");
assert_eq!(b["currencies"][0]["funds"], 29_876_776);
assert_eq!(b["currencies"][0]["finalFunds"], 29_876_776);
assert_eq!(b["currencies"][1]["name"], "points");
assert_eq!(b["currencies"][1]["funds"], 0);
// No packs -> no unopenedPacks key (keeps the badge off).
assert!(b.get("unopenedPacks").is_none());
let b2 = build_credits_body(100, 3);
assert_eq!(b2["unopenedPacks"]["recoveredPacks"], 3);
assert_eq!(b2["unopenedPacks"]["preOrderPacks"], 0);
}
#[test]
fn handle_credits_reads_core_authority() {
let econ = FakeEconomy::ok(4600, 2);
let resp = handle_credits(&econ);
assert_eq!(resp.status, 200);
let body: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(body["currencies"][0]["funds"], 4600);
assert_eq!(body["unopenedPacks"]["recoveredPacks"], 2);
}
#[test]
fn handle_credits_fails_closed_on_core_error() {
// Core error -> controlled 503, NEVER a Python fallback or fabricated balance.
let resp = handle_credits(&FakeEconomy::failing());
assert_eq!(resp.status, 503);
}
#[test]
fn special_filter_keeps_only_specials_and_paginates_filtered_set() {
let mk = |id: i64, rf: i64| serde_json::json!({ "id": id, "rareflag": rf });