diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 877a070..abbb03d 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -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; + fn entitlements(&self) -> Result, CoreError>; + fn purchase_entitlement( + &self, + cost: i64, + definition_id: &str, + ) -> Result; + fn redeem_entitlement( + &self, + entitlement_id: &str, + items: &[EconomyGrantItem], + ) -> Result; + fn sell_item(&self, item_id: &str, price: i64) -> Result; + fn grant_reward(&self, amount: i64) -> Result; + fn purchase_item(&self, cost: i64, item_id: &str, card_id: &str) -> Result; +} + +impl HttpCoreClient { + fn economy_url(&self, tail: &str) -> String { + format!("{}/economy/{}", self.base_url, tail) + } + + fn economy_get(&self, tail: &str) -> Result { + 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 { + 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 { + 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 { + 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 { + json_i64(&self.economy_get("balance")?, "balance") + } + + fn entitlements(&self) -> Result, 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 { + 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 { + let items_json: Vec = 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 { + 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 { + 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 { + 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 = 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: &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, + 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 { + if self.fail { + Err(CoreError::Status(500)) + } else { + Ok(self.balance) + } + } + fn entitlements(&self) -> Result, CoreError> { + if self.fail { + Err(CoreError::Status(500)) + } else { + Ok(self.entitlements.clone()) + } + } + fn purchase_entitlement( + &self, + _cost: i64, + definition_id: &str, + ) -> Result { + Ok(EconomyPurchase { + balance: self.balance, + entitlement_id: format!("bought:{definition_id}"), + }) + } + fn redeem_entitlement( + &self, + _entitlement_id: &str, + _items: &[EconomyGrantItem], + ) -> Result { + Ok("pack".into()) + } + fn sell_item(&self, _item_id: &str, _price: i64) -> Result { + Ok(self.balance) + } + fn grant_reward(&self, _amount: i64) -> Result { + Ok(self.balance) + } + fn purchase_item( + &self, + _cost: i64, + _item_id: &str, + _card_id: &str, + ) -> Result { + 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 });