From 181bd94341e1fea811bb538e6279f9ae12d835ec Mon Sep 17 00:00:00 2001 From: OpenFUT Agent Date: Thu, 13 Aug 2026 19:31:23 +0000 Subject: [PATCH] feat(fifa17): Rust purchasegroup, userMassInfo economy, match reward handlers All Core-backed, fail-closed (503, never Python), NOT yet classifier-routed (coherent barrier pending full cluster + Core seed): - handle_purchasegroup: full Rust body from Core entitlements + StoreMode via the oracle-fixture-tested build_purchasegroup (no Python body dependency). - overlay_massinfo_economy: set userInfo.currencies coins + unopenedPacks recoveredPacks from Core, preserving all other fields. - handle_match_end + build_match_reward_body: derive outcome from endReason, credit via Core grant_reward, oracle-shaped destroy_match_body. Invariant test: credits == userMassInfo == purchasegroup all read one Core state. 7 new host tests (+ FakeEconomy write methods honor fail flag). --- openfut-utas-host/src/lib.rs | 227 +++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index abbb03d..e3e5242 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -45,6 +45,9 @@ use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPo use openfut_adapter_fifa17::fut::club_response::{ shape_club_response, CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats, }; +use openfut_adapter_fifa17::fut::economy_policy::{ + match_reward_total, result_from_end_reason, MatchResult, +}; use openfut_adapter_fifa17::fut::entities::Fifa17Entities; use openfut_adapter_fifa17::fut::owned_query::{ is_special_rareflag, map_to_core, parse_club_query, MapError, @@ -57,6 +60,7 @@ use openfut_adapter_fifa17::fut::squad_projection::{ project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput, SquadProjection, SquadProjectionInput, }; +use openfut_adapter_fifa17::fut::store_catalog::build_purchasegroup; use openfut_adapter_fifa17::fut::store_session::{ validate_capability, SessionStore, StoreMode, SENTINEL_PACK_ID, }; @@ -1390,6 +1394,93 @@ pub fn handle_credits(econ: &dyn CoreEconomy) -> WireResponse { } } +/// Map Core entitlements to FIFA unopened pack ids (definition_id parsed as the +/// numeric pack id; unparseable entries are skipped, never faked). +fn entitlement_pack_ids(ents: &[EconomyEntitlement]) -> Vec { + ents.iter() + .filter_map(|e| e.definition_id.parse::().ok()) + .collect() +} + +/// `GET /store/purchasegroup` fully generated in Rust: normal catalogue packs + +/// Core-owned unopened packs + the empty-My-Packs shim per `StoreMode`. No +/// Python body dependency. Fail-closed on Core error (503, never Python). +pub fn handle_purchasegroup(econ: &dyn CoreEconomy, mode: StoreMode) -> WireResponse { + match econ.entitlements() { + Ok(ents) => { + let ids = entitlement_pack_ids(&ents); + json_response(&build_purchasegroup(&ids, mode)) + } + Err(_) => error_response(503, "core_unavailable"), + } +} + +/// Overlay the authoritative Core economy onto a `userMassInfo` body in place: +/// set `userInfo.currencies[coins].funds/finalFunds` and +/// `userInfo.unopenedPacks.recoveredPacks`. Pure; every other field is +/// preserved. Mirrors the oracle shape (coins element by `name == "coins"`; +/// `unopenedPacks` only present when count > 0). Returns true if applied. +pub fn overlay_massinfo_economy(root: &mut Value, coins: i64, unopened_count: usize) -> bool { + let Some(user_info) = root.get_mut("userInfo").and_then(Value::as_object_mut) else { + return false; + }; + if let Some(currencies) = user_info + .get_mut("currencies") + .and_then(Value::as_array_mut) + { + for cur in currencies.iter_mut() { + if cur.get("name").and_then(Value::as_str) == Some("coins") { + if let Some(obj) = cur.as_object_mut() { + obj.insert("funds".into(), json!(coins)); + obj.insert("finalFunds".into(), json!(coins)); + } + } + } + } + if unopened_count > 0 { + user_info.insert( + "unopenedPacks".into(), + json!({ "preOrderPacks": 0, "recoveredPacks": unopened_count }), + ); + } else { + user_info.remove("unopenedPacks"); + } + true +} + +/// Build the `destroy_match_body` reward response (oracle shape). `total` is the +/// post-credit balance; `result_coins` is the per-result amount. +pub fn build_match_reward_body(total: i64, result: MatchResult) -> Value { + let result_coins = openfut_adapter_fifa17::fut::economy_policy::match_result_coins(result); + let total_award = match_reward_total(result); + json!({ + "allCoins": total, + "matchCoins": result_coins, + "seasonCoins": 0, + "tournamentCoins": 0, + "boostConis": 0, + "participationAward": openfut_adapter_fifa17::fut::economy_policy::MATCH_PARTICIPATION, + "teamOfTournamentWinner": false, + "gameModeAward": { "coins": total_award }, + }) +} + +/// Handle the coin-crediting `/match` end call: derive the outcome from +/// `endReason`, credit the reward through Core `grant_reward`, and render the +/// oracle-shaped body. Fail-closed on Core error (503, never Python). +pub fn handle_match_end(econ: &dyn CoreEconomy, body: &[u8]) -> WireResponse { + let end_reason = serde_json::from_slice::(body).ok().and_then(|v| { + v.get("endReason") + .and_then(Value::as_str) + .map(str::to_string) + }); + let result = result_from_end_reason(end_reason.as_deref()); + match econ.grant_reward(match_reward_total(result)) { + Ok(total) => json_response(&build_match_reward_body(total, result)), + Err(_) => error_response(503, "core_unavailable"), + } +} + // ───────────────────────────── HTTP wire types ────────────────────────────── /// A response ready to write: status, headers, body. @@ -2048,6 +2139,20 @@ mod tests { fail: true, } } + fn with_entitlements(balance: i64, defs: &[&str]) -> Self { + FakeEconomy { + balance, + entitlements: defs + .iter() + .enumerate() + .map(|(i, d)| EconomyEntitlement { + id: format!("e{i}"), + definition_id: (*d).into(), + }) + .collect(), + fail: false, + } + } } impl CoreEconomy for FakeEconomy { fn balance(&self) -> Result { @@ -2069,6 +2174,9 @@ mod tests { _cost: i64, definition_id: &str, ) -> Result { + if self.fail { + return Err(CoreError::Status(500)); + } Ok(EconomyPurchase { balance: self.balance, entitlement_id: format!("bought:{definition_id}"), @@ -2079,12 +2187,21 @@ mod tests { _entitlement_id: &str, _items: &[EconomyGrantItem], ) -> Result { + if self.fail { + return Err(CoreError::Status(500)); + } Ok("pack".into()) } fn sell_item(&self, _item_id: &str, _price: i64) -> Result { + if self.fail { + return Err(CoreError::Status(500)); + } Ok(self.balance) } fn grant_reward(&self, _amount: i64) -> Result { + if self.fail { + return Err(CoreError::Status(500)); + } Ok(self.balance) } fn purchase_item( @@ -2093,6 +2210,9 @@ mod tests { _item_id: &str, _card_id: &str, ) -> Result { + if self.fail { + return Err(CoreError::Status(500)); + } Ok(self.balance) } } @@ -2130,6 +2250,113 @@ mod tests { assert_eq!(resp.status, 503); } + fn pack_ids(body: &Value) -> Vec { + body["purchase"] + .as_array() + .unwrap() + .iter() + .map(|p| p["id"].as_u64().unwrap()) + .collect() + } + + #[test] + fn purchasegroup_full_gen_owned_pack_no_sentinel() { + let econ = FakeEconomy::with_entitlements(4600, &["70"]); + let resp = handle_purchasegroup(&econ, StoreMode::Sentinel); + assert_eq!(resp.status, 200); + let body: Value = serde_json::from_slice(&resp.body).unwrap(); + let ids = pack_ids(&body); + assert!(ids.contains(&70), "owned pack 70 present"); + assert!( + !ids.contains(&SENTINEL_PACK_ID), + "no sentinel when a pack is owned" + ); + } + + #[test] + fn purchasegroup_full_gen_empty_modes() { + // Sentinel mode + no packs -> 65534 shim present. + let sent = handle_purchasegroup(&FakeEconomy::ok(100, 0), StoreMode::Sentinel); + let sent_body: Value = serde_json::from_slice(&sent.body).unwrap(); + assert!(pack_ids(&sent_body).contains(&SENTINEL_PACK_ID)); + // CleanV1 + no packs -> no 65534, no My Packs group. + let clean = handle_purchasegroup(&FakeEconomy::ok(100, 0), StoreMode::CleanV1); + let clean_body: Value = serde_json::from_slice(&clean.body).unwrap(); + assert!(!pack_ids(&clean_body).contains(&SENTINEL_PACK_ID)); + } + + #[test] + fn purchasegroup_fails_closed_on_core_error() { + let resp = handle_purchasegroup(&FakeEconomy::failing(), StoreMode::Sentinel); + assert_eq!(resp.status, 503); + } + + #[test] + fn massinfo_economy_overlay_sets_coins_and_packs() { + let mut root = json!({ + "userInfo": { + "currencies": [ + {"name": "coins", "funds": 1, "finalFunds": 1, "active": true}, + {"name": "points", "funds": 0, "finalFunds": 0, "active": true}, + ], + "won": 5, + }, + "squad": {"keep": true}, + }); + assert!(overlay_massinfo_economy(&mut root, 29_876_776, 2)); + assert_eq!(root["userInfo"]["currencies"][0]["funds"], 29_876_776); + assert_eq!(root["userInfo"]["currencies"][0]["finalFunds"], 29_876_776); + // points + other fields untouched; squad preserved. + assert_eq!(root["userInfo"]["currencies"][1]["funds"], 0); + assert_eq!(root["userInfo"]["won"], 5); + assert_eq!(root["squad"]["keep"], true); + assert_eq!(root["userInfo"]["unopenedPacks"]["recoveredPacks"], 2); + // Zero packs removes the key (badge off). + assert!(overlay_massinfo_economy(&mut root, 10, 0)); + assert!(root["userInfo"].get("unopenedPacks").is_none()); + } + + #[test] + fn credits_massinfo_purchasegroup_agree_on_core_state() { + // The invariant the cutover must preserve: all three read one Core state. + let econ = FakeEconomy::with_entitlements(4600, &["70"]); + let coins = econ.balance().unwrap(); + let count = econ.entitlements().unwrap().len(); + let credits: Value = serde_json::from_slice(&handle_credits(&econ).body).unwrap(); + let mut mass = + json!({"userInfo": {"currencies": [{"name":"coins","funds":0,"finalFunds":0}]}}); + overlay_massinfo_economy(&mut mass, coins, count); + let pg: Value = + serde_json::from_slice(&handle_purchasegroup(&econ, StoreMode::Sentinel).body).unwrap(); + assert_eq!(credits["currencies"][0]["funds"], coins); + assert_eq!(mass["userInfo"]["currencies"][0]["funds"], coins); + assert_eq!(credits["unopenedPacks"]["recoveredPacks"], count); + assert_eq!(mass["userInfo"]["unopenedPacks"]["recoveredPacks"], count); + assert!(pack_ids(&pg).contains(&70)); + } + + #[test] + fn match_reward_credits_core_and_shapes_body() { + let econ = FakeEconomy::ok(5400, 0); // grant_reward echoes balance + let resp = handle_match_end(&econ, br#"{"endReason":"WIN"}"#); + assert_eq!(resp.status, 200); + let body: Value = serde_json::from_slice(&resp.body).unwrap(); + assert_eq!(body["allCoins"], 5400); + assert_eq!(body["matchCoins"], 400); // win + assert_eq!(body["gameModeAward"]["coins"], 400); + assert_eq!(body["seasonCoins"], 0); + // Draw default on unknown reason. + let draw: Value = + serde_json::from_slice(&handle_match_end(&econ, br#"{"foo":1}"#).body).unwrap(); + assert_eq!(draw["matchCoins"], 200); + } + + #[test] + fn match_reward_fails_closed_on_core_error() { + let resp = handle_match_end(&FakeEconomy::failing(), br#"{"endReason":"WIN"}"#); + 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 });