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::{
|
use openfut_adapter_fifa17::fut::club_response::{
|
||||||
shape_club_response, CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats,
|
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::entities::Fifa17Entities;
|
||||||
use openfut_adapter_fifa17::fut::owned_query::{
|
use openfut_adapter_fifa17::fut::owned_query::{
|
||||||
is_special_rareflag, map_to_core, parse_club_query, MapError,
|
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,
|
project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
|
||||||
SquadProjection, SquadProjectionInput,
|
SquadProjection, SquadProjectionInput,
|
||||||
};
|
};
|
||||||
|
use openfut_adapter_fifa17::fut::store_catalog::build_purchasegroup;
|
||||||
use openfut_adapter_fifa17::fut::store_session::{
|
use openfut_adapter_fifa17::fut::store_session::{
|
||||||
validate_capability, SessionStore, StoreMode, SENTINEL_PACK_ID,
|
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 ──────────────────────────────
|
// ───────────────────────────── HTTP wire types ──────────────────────────────
|
||||||
|
|
||||||
/// A response ready to write: status, headers, body.
|
/// A response ready to write: status, headers, body.
|
||||||
@@ -2048,6 +2139,20 @@ mod tests {
|
|||||||
fail: true,
|
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 {
|
impl CoreEconomy for FakeEconomy {
|
||||||
fn balance(&self) -> Result<i64, CoreError> {
|
fn balance(&self) -> Result<i64, CoreError> {
|
||||||
@@ -2069,6 +2174,9 @@ mod tests {
|
|||||||
_cost: i64,
|
_cost: i64,
|
||||||
definition_id: &str,
|
definition_id: &str,
|
||||||
) -> Result<EconomyPurchase, CoreError> {
|
) -> Result<EconomyPurchase, CoreError> {
|
||||||
|
if self.fail {
|
||||||
|
return Err(CoreError::Status(500));
|
||||||
|
}
|
||||||
Ok(EconomyPurchase {
|
Ok(EconomyPurchase {
|
||||||
balance: self.balance,
|
balance: self.balance,
|
||||||
entitlement_id: format!("bought:{definition_id}"),
|
entitlement_id: format!("bought:{definition_id}"),
|
||||||
@@ -2079,12 +2187,21 @@ mod tests {
|
|||||||
_entitlement_id: &str,
|
_entitlement_id: &str,
|
||||||
_items: &[EconomyGrantItem],
|
_items: &[EconomyGrantItem],
|
||||||
) -> Result<String, CoreError> {
|
) -> Result<String, CoreError> {
|
||||||
|
if self.fail {
|
||||||
|
return Err(CoreError::Status(500));
|
||||||
|
}
|
||||||
Ok("pack".into())
|
Ok("pack".into())
|
||||||
}
|
}
|
||||||
fn sell_item(&self, _item_id: &str, _price: i64) -> Result<i64, CoreError> {
|
fn sell_item(&self, _item_id: &str, _price: i64) -> Result<i64, CoreError> {
|
||||||
|
if self.fail {
|
||||||
|
return Err(CoreError::Status(500));
|
||||||
|
}
|
||||||
Ok(self.balance)
|
Ok(self.balance)
|
||||||
}
|
}
|
||||||
fn grant_reward(&self, _amount: i64) -> Result<i64, CoreError> {
|
fn grant_reward(&self, _amount: i64) -> Result<i64, CoreError> {
|
||||||
|
if self.fail {
|
||||||
|
return Err(CoreError::Status(500));
|
||||||
|
}
|
||||||
Ok(self.balance)
|
Ok(self.balance)
|
||||||
}
|
}
|
||||||
fn purchase_item(
|
fn purchase_item(
|
||||||
@@ -2093,6 +2210,9 @@ mod tests {
|
|||||||
_item_id: &str,
|
_item_id: &str,
|
||||||
_card_id: &str,
|
_card_id: &str,
|
||||||
) -> Result<i64, CoreError> {
|
) -> Result<i64, CoreError> {
|
||||||
|
if self.fail {
|
||||||
|
return Err(CoreError::Status(500));
|
||||||
|
}
|
||||||
Ok(self.balance)
|
Ok(self.balance)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2130,6 +2250,113 @@ mod tests {
|
|||||||
assert_eq!(resp.status, 503);
|
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]
|
#[test]
|
||||||
fn special_filter_keeps_only_specials_and_paginates_filtered_set() {
|
fn special_filter_keeps_only_specials_and_paginates_filtered_set() {
|
||||||
let mk = |id: i64, rf: i64| serde_json::json!({ "id": id, "rareflag": rf });
|
let mk = |id: i64, rf: i64| serde_json::json!({ "id": id, "rareflag": rf });
|
||||||
|
|||||||
Reference in New Issue
Block a user