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