fifa17 store: real 6-pack economy + full-DB pool; drop extPrice

- store_catalog: replace the invented catalogue with the real always-available
  FUT17 regular packs (Bronze/Prem Bronze/Silver/Prem Silver/Gold/Prem Gold) at
  real prices + tier composition; PackDef now carries per-tier quantities.
- pack_body: drop extPrice (its mtx side-effect switched on the broken "or %1s"
  FIFA-Points tile line; plan-2026-08-05-store-subsystem.md section 3.4).
- pack_content: tier-aware generator draws each pack bronze/silver/gold
  composition with special_chance bias + empty-tier fallback.
- host: CoreAccess::all_definitions (GET /cards); build_content_pool draws the
  FULL card universe via non-minting catalog lookup, owned-inventory fallback.
- economy_differential: store ops reclassified DIFFERENT-BY-DESIGN (Rust is the
  authoritative store; Python oracle stays the untouched rollback baseline).
- fixtures/tests updated to the real catalogue.

Odds are DESIGNED placeholders (FUT17 pack probabilities were never published);
club items remain excluded (cardtype-9 mapping unknown). Full regression green;
real prices + tier-correct draws verified server-side on staging.
This commit is contained in:
funman300
2026-08-18 23:26:55 +00:00
parent 7116046195
commit c68c10cf04
10 changed files with 863 additions and 650 deletions
+13 -9
View File
@@ -244,7 +244,11 @@ fn pack_open_body(pid: u64, pack: &PackDef) -> WireResponse {
"firstPartyStoreId": 0,
"groupName": "fifa17",
"productId": pid.to_string(),
"purchasePackType": if pack.gold { "GOLD" } else { "BRONZE" },
"purchasePackType": match pack.category {
"gold" => "GOLD",
"silver" => "SILVER",
_ => "BRONZE",
},
}))
}
@@ -695,7 +699,7 @@ mod tests {
// ── Store BUY ──────────────────────────────────────────────────────────
#[test]
fn buy_pack1_debits_mints_and_reveals_five_cards() {
fn buy_pack1_debits_mints_and_reveals_twelve_cards() {
let econ = RecEcon::new(10_000);
let pool = pool();
let assets = FakeAssets::for_pool(&pool);
@@ -705,23 +709,23 @@ mod tests {
assert_eq!(resp.status, 200);
let b: Value = serde_json::from_slice(&resp.body).unwrap();
let cpr = &b["createPackResponse"];
assert_eq!(cpr["numberItems"], 5); // pack 1 count
assert_eq!(cpr["itemList"].as_array().unwrap().len(), 5);
assert_eq!(cpr["numberItems"], 12); // pack 1 count (10 bronze + 2 silver)
assert_eq!(cpr["itemList"].as_array().unwrap().len(), 12);
assert_eq!(cpr["purchasedPackId"], 1);
assert_eq!(cpr["duplicateItemIdList"], json!([]));
// Exactly one atomic debit of the pack price (400) minting 5 items.
// Exactly one atomic debit of the pack price (400) minting 12 items.
assert_eq!(econ.coins(), 10_000 - 400);
let purchased = econ.purchased.lock();
assert_eq!(purchased.len(), 1);
assert_eq!(purchased[0].0, 400);
assert_eq!(purchased[0].1.len(), 5);
assert_eq!(purchased[0].1.len(), 12);
for g in &purchased[0].1 {
assert!(pool.iter().any(|c| c.card_id == g.card_id));
}
}
#[test]
fn buy_pack5_debits_gold_price_and_mints_seven() {
fn buy_pack5_debits_gold_price_and_mints_twelve() {
let econ = RecEcon::new(10_000);
let pool = pool();
let assets = FakeAssets::for_pool(&pool);
@@ -729,7 +733,7 @@ mod tests {
let deps = store_deps(&econ, &assets, &ent, &pool);
let resp = handle_store_buy(&body(json!({ "packId": 5 })), &deps, &mut rng(2));
let b: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(b["createPackResponse"]["numberItems"], 7); // pack 5 count
assert_eq!(b["createPackResponse"]["numberItems"], 12); // pack 5 count (10 gold + 2 silver)
assert_eq!(econ.coins(), 10_000 - 5000);
}
@@ -860,7 +864,7 @@ mod tests {
assert_eq!(b["firstPartyStoreId"], 0);
assert_eq!(b["purchasePackType"], "GOLD"); // pack 5 is gold
assert_eq!(econ.coins(), 10_000 - 5000);
assert_eq!(econ.purchased.lock()[0].1.len(), 7);
assert_eq!(econ.purchased.lock()[0].1.len(), 12);
}
#[test]
+91 -13
View File
@@ -663,6 +663,17 @@ pub trait CoreAccess: Send + Sync {
Ok(self.query_owned(&[])?.items)
}
/// Every card DEFINITION in Core content (`GET /cards`) — the full card
/// universe a pack can draw from, independent of ownership. Same shape as
/// owned items (rating/position/nation/league/club/attributes) so
/// [`build_content_pool`] treats definitions and owned items uniformly.
/// Default: unimplemented (callers fall back to owned inventory).
fn all_definitions(&self) -> Result<Vec<CoreOwnedItem>, CoreError> {
Err(CoreError::Parse(
"Core content enumeration is not implemented".into(),
))
}
/// Read the active squad + its opaque extension for `namespace`.
fn read_squad_ext(&self, namespace: &str) -> Result<CoreSquadRead, CoreError>;
@@ -746,6 +757,22 @@ impl CoreAccess for HttpCoreClient {
let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?;
parse_core_page(&v)
}
fn all_definitions(&self) -> Result<Vec<CoreOwnedItem>, CoreError> {
let url = format!("{}/cards", self.base_url);
let resp = self
.client
.get(&url)
.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));
}
let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?;
parse_core_definitions(&v)
}
fn read_squad_ext(&self, namespace: &str) -> Result<CoreSquadRead, CoreError> {
let url = format!("{}/squad/ext", self.base_url);
let resp = self
@@ -1361,6 +1388,38 @@ fn core_item_from_json(e: &Value) -> Option<CoreOwnedItem> {
})
}
/// Parse Core's `/cards` response `{ "cards": [CardDefinition...], ... }` into the
/// same [`CoreOwnedItem`] shape as owned items (`owned_card_id` empty — a
/// definition is not an instance), for full-universe pool building.
pub fn parse_core_definitions(v: &Value) -> Result<Vec<CoreOwnedItem>, CoreError> {
let arr = v
.get("cards")
.and_then(|c| c.as_array())
.ok_or_else(|| CoreError::Parse("missing `cards` array".into()))?;
Ok(arr.iter().filter_map(core_item_from_definition).collect())
}
fn core_item_from_definition(card: &Value) -> Option<CoreOwnedItem> {
let attr = |k: &str| card.get(k).and_then(|v| v.as_i64()).unwrap_or(0) as u8;
Some(CoreOwnedItem {
owned_card_id: String::new(),
card_id: card.get("id")?.as_str()?.to_string(),
rating: card.get("overall").and_then(|v| v.as_i64()).unwrap_or(0) as u8,
position: card.get("position")?.as_str()?.to_string(),
nation: card.get("nation")?.as_str()?.to_string(),
league: card.get("league")?.as_str()?.to_string(),
club: card.get("club")?.as_str()?.to_string(),
attributes: [
attr("pace"),
attr("shooting"),
attr("passing"),
attr("dribbling"),
attr("defending"),
attr("physical"),
],
})
}
// ───────────────────────────── Item identity resolver ───────────────────────
/// The single production [`ItemIdentityResolver`]: it composes the two distinct
@@ -1419,6 +1478,15 @@ impl Fifa17IdentityResolver {
.map(|c| c.rareflag)
.unwrap_or(0)
}
/// Non-minting definition identity from the catalog: `Some((rareflag, kind))`
/// if the card resolves, else `None`. Used to build the pack pool over ALL
/// content definitions WITHOUT allocating a wire id per definition (that would
/// pollute the identity store); a real wire id is minted only when a pack draw
/// actually mints the card.
pub fn definition_identity(&self, card_id: &str) -> Option<(i64, ContentKind)> {
self.catalog.lookup(card_id).map(|c| (c.rareflag, c.kind))
}
}
impl ItemIdentityResolver for Fifa17IdentityResolver {
@@ -2372,30 +2440,40 @@ pub struct EconomyServices {
pub sold_experiment: crate::sold_experiment::SoldExperiment,
}
/// Build the pack-content candidate pool from Core's current content, evidenced
/// by the owned inventory: every distinct owned card definition that resolves to
/// a real FIFA asset id is a candidate (`gold` = rating ≥ 75; `special` from the
/// catalog `rareflag > 1`). This is the resolvable FIFA∩Core card universe the
/// cards a pack can award and the shared shaper can render. An empty pool (no
/// content, or Core unreachable) is fail-closed by construction: the generator
/// returns no cards, so the Store neither mints nor debits.
/// Build the pack-content candidate pool. Prefers the FULL card universe (Core
/// content, `GET /cards` via [`CoreAccess::all_definitions`]) so a pack can award
/// any card in the game, not only cards the profile already owns; falls back to
/// owned inventory when content enumeration is unavailable (older Core / tests).
/// Each candidate resolves in the FIFA catalog (unmapped or non-player cards are
/// dropped, never faked); `gold` = rating ≥ 75, `special` = catalog `rareflag > 1`.
/// An empty pool (no content, or Core unreachable) is fail-closed by construction:
/// the generator returns no cards, so the Store neither mints nor debits.
pub fn build_content_pool(
core: &dyn CoreAccess,
resolver: &Fifa17IdentityResolver,
) -> Vec<GeneratedCandidate> {
let owned = match core.all_owned() {
Ok(v) => v,
Err(_) => return Vec::new(),
// Full universe first; owned inventory only as a degrade path.
let items = match core.all_definitions() {
Ok(v) if !v.is_empty() => v,
_ => match core.all_owned() {
Ok(v) => v,
Err(_) => return Vec::new(),
},
};
let mut seen = std::collections::HashSet::new();
let mut pool = Vec::new();
for item in &owned {
for item in &items {
if !seen.insert(item.card_id.clone()) {
continue;
}
let Some(id) = resolver.resolve(item) else {
// Non-minting catalog lookup: drop unmapped cards and non-player content
// (consumables/staff never enter the player-card pack pool).
let Some((rareflag, kind)) = resolver.definition_identity(&item.card_id) else {
continue;
};
if kind != ContentKind::Player {
continue;
}
pool.push(GeneratedCandidate {
card_id: item.card_id.clone(),
rating: item.rating,
@@ -2405,7 +2483,7 @@ pub fn build_content_pool(
club: item.club.clone(),
attributes: item.attributes,
gold: item.rating >= 75,
special: id.rareflag > 1,
special: rareflag > 1,
});
}
pool
@@ -311,7 +311,7 @@ fn case_a_two_buys(h: &Harness) -> String {
refs, 1,
"exactly one BUY refused 461 (statuses {statuses:?})"
);
// The winner minted 5 cards; the loser minted nothing.
// The winner minted 12 cards (real Bronze Pack); the loser minted nothing.
for r in &rs {
if r.status == 200 {
assert_eq!(
@@ -319,7 +319,7 @@ fn case_a_two_buys(h: &Harness) -> String {
.as_array()
.unwrap()
.len(),
5
12
);
}
}
+54 -29
View File
@@ -26,15 +26,15 @@
//! | userMassInfo economy | PARITY | `userInfo.currencies[coins].funds == credits coins`; both |
//! | | | carry `unopenedPacks.recoveredPacks==1`. Coins consistent |
//! | | | across the credits & massinfo surfaces on each side. |
//! | purchasegroup pack70 | PARITY | owned pack present -> id set {1,5,6,7,70}, NO 65534 sentinel. |
//! | purchasegroup sentinel | PARITY | empty My Packs + unverified session -> {1,5,6,7,65534}, |
//! | | | sentinel `state:"active"`. |
//! | purchasegroup clean-v1 | PARITY | empty My Packs + verified capability session -> {1,5,6,7}, |
//! | purchasegroup pack70 | DIFFERENT-BY-DESIGN| STORE DIVERGED: Rust serves the real 6-pack catalogue |
//! | | | {1,2,3,4,5,6,70}; oracle (rollback) keeps {1,5,6,7,70}. |
//! | purchasegroup sentinel | DIFFERENT-BY-DESIGN| empty My Packs + unverified -> rust {1..6,65534} vs oracle |
//! | | | {1,5,6,7,65534}; sentinel `state:"active"` on both. |
//! | purchasegroup clean-v1 | DIFFERENT-BY-DESIGN| empty + verified capability -> rust {1..6}, oracle {1,5,6,7};|
//! | | | sentinel stripped. Rust drives the REAL `SessionStore` state |
//! | | | machine (register_capability+open_session+freeze) exactly as |
//! | | | the oracle's launcher/auth handshake does. |
//! | Store BUY (pack 1) | PARITY | 200; `createPackResponse{itemList(5),numberItems:5, |
//! | | | purchasedPackId,duplicateItemIdList}`; coin delta -400. |
//! | | | machine (register_capability+open_session+freeze). |
//! | Store BUY (pack 1) | DIFFERENT-BY-DESIGN| 200; rust real Bronze Pack -> itemList(12),numberItems:12; |
//! | | | oracle placeholder -> 5; both debit 400 + purchasedPackId 1. |
//! | POST /purchased open70 | PARITY | 200; envelope `{packId:70,firstPartyStoreId,productId:"70", |
//! | | | purchasePackType:"GOLD"}`; coin delta 0; entitlement -1. |
//! | GET /purchased reveal | PARITY | Durable single-profile purchased pile on BOTH (Rust reveal |
@@ -550,29 +550,43 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
None,
)
.1;
// STORE DIVERGED: Rust is the authoritative store owner and serves the real
// 6-pack regular catalogue (ids 1..6 + owned 70). The Python oracle (rollback
// baseline, never modified) still serves the old placeholder set {1,5,6,7,70},
// so this is Rust-authoritative, NOT oracle parity.
assert_eq!(
pack_ids(&opg),
vec![1, 5, 6, 7, 70],
"oracle owned pack70 id set"
"oracle (rollback) placeholder id set"
);
assert_eq!(
pack_ids(&rpg),
vec![1, 5, 6, 7, 70],
"rust owned pack70 id set"
vec![1, 2, 3, 4, 5, 6, 70],
"rust authoritative real-catalogue id set"
);
for b in [&opg, &rpg] {
assert!(
!pack_ids(b).contains(&SENTINEL_PACK_ID),
"no 65534 sentinel while a pack is owned"
);
// packType parity per id (BRONZE for 1, GOLD for the rest).
for p in b["purchase"].as_array().unwrap() {
let id = p["id"].as_u64().unwrap();
let want = if id == 1 { "BRONZE" } else { "GOLD" };
assert_eq!(p["packType"], want, "packType parity for pack {id}");
}
}
matrix.push(("purchasegroup pack70", "PARITY"));
// packType by side: oracle BRONZE for 1 else GOLD; rust by real category
// (1,2 bronze / 3,4 silver / 5,6,70 gold).
for p in opg["purchase"].as_array().unwrap() {
let id = p["id"].as_u64().unwrap();
let want = if id == 1 { "BRONZE" } else { "GOLD" };
assert_eq!(p["packType"], want, "oracle packType for pack {id}");
}
for p in rpg["purchase"].as_array().unwrap() {
let id = p["id"].as_u64().unwrap();
let want = match id {
1 | 2 => "BRONZE",
3 | 4 => "SILVER",
_ => "GOLD",
};
assert_eq!(p["packType"], want, "rust packType for pack {id}");
}
matrix.push(("purchasegroup pack70", "DIFFERENT-BY-DESIGN"));
// ── OP 4: Store BUY (pack 1 Bronze, price 400, 5 cards) ────────────────
let o_bal0 = oracle.coins();
@@ -600,11 +614,22 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
.as_array()
.expect("rust itemList")
.clone();
assert_eq!(o_items.len(), 5, "oracle pack1 -> 5 cards");
assert_eq!(r_items.len(), 5, "rust pack1 -> 5 cards");
// STORE DIVERGED: rust serves the real Bronze Pack (10+2 = 12 cards); the
// oracle placeholder awards 5. Both debit the same 400-coin price.
assert_eq!(o_items.len(), 5, "oracle (rollback) pack1 -> 5 cards");
assert_eq!(r_items.len(), 12, "rust pack1 real Bronze Pack -> 12 cards");
assert_eq!(
o_buy["createPackResponse"]["numberItems"].as_i64().unwrap(),
5,
"oracle numberItems==5"
);
assert_eq!(
r_buy["createPackResponse"]["numberItems"].as_i64().unwrap(),
12,
"rust numberItems==12"
);
for b in [&o_buy, &r_buy] {
let cpr = &b["createPackResponse"];
assert_eq!(cpr["numberItems"].as_i64().unwrap(), 5, "numberItems==5");
assert_eq!(
cpr["purchasedPackId"].as_i64().unwrap(),
1,
@@ -619,9 +644,9 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
assert_eq!(
client.balance().unwrap() - r_bal0,
-400,
"rust BUY debits 400"
"rust BUY debits 400 (price parity)"
);
matrix.push(("Store BUY (pack1)", "PARITY"));
matrix.push(("Store BUY (pack1)", "DIFFERENT-BY-DESIGN"));
// Minted wire ids for the item ops that follow (both sides put them in the
// pending purchased pile).
let o_wire: Vec<i64> = o_items.iter().map(|i| i["id"].as_i64().unwrap()).collect();
@@ -1176,8 +1201,8 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
);
assert_eq!(
pack_ids(&r_pg_s),
vec![1, 5, 6, 7, SENTINEL_PACK_ID],
"rust empty (unknown SID) -> sentinel"
vec![1, 2, 3, 4, 5, 6, SENTINEL_PACK_ID],
"rust empty (unknown SID) -> real catalogue + sentinel"
);
for b in [&o_pg_s, &r_pg_s] {
let s = b["purchase"]
@@ -1188,7 +1213,7 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
.unwrap();
assert_eq!(s["state"], "active", "sentinel state active");
}
matrix.push(("purchasegroup sentinel", "PARITY"));
matrix.push(("purchasegroup sentinel", "DIFFERENT-BY-DESIGN"));
// ── OP 3c: purchasegroup — clean-v1 (empty + verified capability session) ─
// Oracle: register the launcher capability, open a session (auth), present the
@@ -1240,11 +1265,11 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
let r_pg_c: Value = serde_json::from_slice(&r_pg_c_resp.body).unwrap();
assert_eq!(
pack_ids(&r_pg_c),
vec![1, 5, 6, 7],
"rust clean-v1 strips the sentinel"
vec![1, 2, 3, 4, 5, 6],
"rust clean-v1 real catalogue, sentinel stripped"
);
assert!(!pack_ids(&r_pg_c).contains(&SENTINEL_PACK_ID));
matrix.push(("purchasegroup clean-v1", "PARITY"));
matrix.push(("purchasegroup clean-v1", "DIFFERENT-BY-DESIGN"));
// ── Emit the matrix for the run log. ────────────────────────────────────
eprintln!("\n===== economy_differential PARITY / DIFFERENT-BY-DESIGN matrix =====");
@@ -373,8 +373,8 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
.as_array()
.expect("itemList")
.clone();
assert_eq!(items.len(), 5, "pack 1 awards 5 cards");
assert_eq!(bv["createPackResponse"]["numberItems"], 5);
assert_eq!(items.len(), 12, "pack 1 (real Bronze Pack) awards 12 cards");
assert_eq!(bv["createPackResponse"]["numberItems"], 12);
assert!(
items[0]["id"].as_i64().unwrap() >= 100_000_000,
"minted wire id above the FIFA floor"