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:
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user