wip(fifa17): pre-existing SBC/economy candidate snapshot

Snapshot of the uncommitted economy/SBC candidate work that built the tested
sbc-host on top of e8d1c1d (NOT authored in this session; committed to leave a
clean tree). Covers pack_content, sbc, store_catalog, host economy_store/lib,
purchasegroup fixtures, economy integration/concurrency/differential tests,
and Cargo.lock. Content matches the running staging host binary.
This commit is contained in:
funman300
2026-08-19 20:12:06 +00:00
parent 3bb4814760
commit 8afb812338
12 changed files with 1189 additions and 665 deletions
+100 -17
View File
@@ -73,7 +73,9 @@ 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_catalog::{
build_purchasegroup, owned_pack_id_for_definition,
};
use openfut_adapter_fifa17::fut::store_session::{
validate_capability, SessionStore, StoreMode, SENTINEL_PACK_ID,
};
@@ -663,6 +665,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 +759,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 +1390,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 +1480,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 {
@@ -2148,11 +2218,14 @@ 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).
/// Map Core entitlements to FIFA 17 unopened pack ids. `definition_id` is either
/// a numeric owned-only pack id (imported entitlements) or a symbolic reward-pack
/// name granted by Core's reward services; both resolve via
/// [`owned_pack_id_for_definition`]. Unresolvable entitlements are skipped, never
/// faked.
fn entitlement_pack_ids(ents: &[EconomyEntitlement]) -> Vec<u64> {
ents.iter()
.filter_map(|e| e.definition_id.parse::<u64>().ok())
.filter_map(|e| owned_pack_id_for_definition(&e.definition_id))
.collect()
}
@@ -2372,30 +2445,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 +2488,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