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).
This commit is contained in:
OpenFUT Agent
2026-08-13 19:31:23 +00:00
parent 09675a9f7f
commit 181bd94341
+227
View File
@@ -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<u64> {
ents.iter()
.filter_map(|e| e.definition_id.parse::<u64>().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::<Value>(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<i64, CoreError> {
@@ -2069,6 +2174,9 @@ mod tests {
_cost: i64,
definition_id: &str,
) -> Result<EconomyPurchase, CoreError> {
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<String, CoreError> {
if self.fail {
return Err(CoreError::Status(500));
}
Ok("pack".into())
}
fn sell_item(&self, _item_id: &str, _price: i64) -> Result<i64, CoreError> {
if self.fail {
return Err(CoreError::Status(500));
}
Ok(self.balance)
}
fn grant_reward(&self, _amount: i64) -> Result<i64, CoreError> {
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<i64, CoreError> {
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<u64> {
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 });