economy(fifa17): land Store + Market writer handlers + pack generator (unrouted)
Implements the FIFA17 economy WRITER cluster on top of the landed Core
economy authority + host CoreEconomy client + identity/item-shaper infra.
Handlers are pub, unit-tested, and NOT yet routed: classify() and
ROUTE_AUTHORITY are untouched — the classifier barrier is a later single
coherent flip. No stubs; real Core-backed behavior; fail-closed on CoreError.
Pack generator (adapter fut/pack_content.rs):
generate_pack_contents(&PackDef, &mut impl Rng, &[GeneratedCandidate])
-> Vec<GeneratedCard>. Pure, seeded (deterministic), gold-tier split +
special_chance gate as documented OPENFUT PLACEHOLDER policy (Python
open_pack/_pack_body parity note inline). Fail-closed empty on empty pool.
Store/item writers (host economy_store.rs), matching oracle wire shapes:
- handle_store_buy PUT /store/transaction -> purchase_items (debit+mint N)
-> createPackResponse; cancel/unknown/owned_only -> 200 {}; insufficient
-> 461 {reason,credits}; CoreError -> 503.
- handle_pack_open POST /purchased -> owned_only consumes the unopened
entitlement (redeem_entitlement, consume-once); normal packs debit+mint.
- handle_quick_sell{_path,_body} DELETE .../item/<id> + POST /ut/delete/.../item
-> reverse-resolve wire->Core id (SquadWireResolver) -> sell_item ->
{items:[{id}],totalCredits}; not-owned skipped.
Production OwnedItemLookup = CoreItemLookup over CoreAccess.
Market (host market_store.rs / pile_store.rs / market.rs), synthetic-seller:
- MarketStore over sqlx SQLite (WAL-once + busy_timeout=5s + BEGIN IMMEDIATE
for writes, mirroring openfut-core::db). listings(active/reserved/sold/
cancelled), owner-checked cancel, CAS reserve/complete_sale/rollback.
Typed errors NotFound/Sold/Cancelled/WrongOwner/Conflict.
- PileStore: durable pile/location metadata keyed by Core item id.
- handle_market_{list,query,cancel,buy} + handle_move_items. Buy-now =
reserve (CAS) -> balance precheck (461) -> Core purchase_item (mint+debit)
-> complete_sale; any Core failure rolls the reservation back active.
Two concurrent buyers -> exactly one sale + one debit.
Deps (additive): rand 0.8 (adapter+host), sqlx 0.7 sqlite/runtime-tokio (host).
Tests: adapter +7 (pack_content), host +43 (economy_store 20, market/store 23
incl two_reservers_exactly_one_wins, two_buyers_exactly_one_sale_one_debit,
state_survives_reopen, move_persists_across_reopen). All green; clippy
-D warnings clean; rustfmt clean.
This commit is contained in:
@@ -11,6 +11,7 @@ pub mod economy_policy;
|
||||
pub mod entities;
|
||||
pub mod item;
|
||||
pub mod owned_query;
|
||||
pub mod pack_content;
|
||||
pub mod squad;
|
||||
pub mod squad_ext;
|
||||
pub mod squad_projection;
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
//! FIFA 17 Store pack-content generator (pure, seeded).
|
||||
//!
|
||||
//! Draws the cards a Store pack awards. It is a **pure function** of
|
||||
//! `(pack definition, RNG, candidate pool)` — no IO, no Core, no catalogue
|
||||
//! lookup — so it is deterministic under a seeded [`rand::Rng`] and trivially
|
||||
//! unit-tested. The host owns the impure parts: it builds the candidate pool
|
||||
//! (only card ids that resolve in BOTH the FIFA catalogue and Core content),
|
||||
//! mints the drawn cards into Core, and shapes them onto the wire.
|
||||
//!
|
||||
//! ## Parity note — Python `open_pack` / `_pack_body`
|
||||
//! (`fifa17-recon/tools/fut_store.py:689`, `utas_server.py:3474`)
|
||||
//! 1. `open_pack(price, count, gold, tiers, special_chance)` deducts coins then
|
||||
//! draws `count` items (mostly players); the reveal body wraps them verbatim.
|
||||
//! 2. Non-tiered draws split the pool at rating 75 by `gold` (`p[1] >= 75 == gold`)
|
||||
//! and fall back to the whole pool when that tier is empty (`... or PACK_POOL`).
|
||||
//! 3. Each drawn player becomes a special with probability `special_chance`
|
||||
//! (`random.random() < special_chance`).
|
||||
//! 4. `FUT_PACK_MIX` swaps ~`count // 4` players for consumables/staff extras;
|
||||
//! we deliberately OMIT that mix (Core candidates are player defs — players-only).
|
||||
//! 5. Prices/counts/odds are the OpenFUT **PLACEHOLDER** economy (the audit found
|
||||
//! them invented); only the wire *shape* is EA-observed/oracle-verified.
|
||||
//! 6. This port reproduces the count + gold-tier split + `special_chance` gate as
|
||||
//! that same PLACEHOLDER policy, drawing with replacement from the pool.
|
||||
|
||||
use rand::Rng;
|
||||
|
||||
use crate::fut::store_catalog::PackDef;
|
||||
|
||||
/// A candidate the host has already verified resolves in BOTH the FIFA catalogue
|
||||
/// and Core content. Carries the full Core definition the shaper needs plus the
|
||||
/// two draw-policy annotations (`gold` tier, `special` version) the host derives
|
||||
/// from the catalogue (keeping this generator pure — it never reads a catalogue).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GeneratedCandidate {
|
||||
/// Core card-definition id (resolves in the FIFA catalogue and Core content).
|
||||
pub card_id: String,
|
||||
pub rating: u8,
|
||||
pub position: String,
|
||||
pub nation: String,
|
||||
pub league: String,
|
||||
pub club: String,
|
||||
/// [pace, shooting, passing, dribbling, defending, physical].
|
||||
pub attributes: [u8; 6],
|
||||
/// Gold tier (host derives this as `rating >= 75`, the oracle's split point).
|
||||
pub gold: bool,
|
||||
/// Special version available (host derives this from the catalogue rareflag
|
||||
/// `> 1`); gated by [`PackDef::special_chance`].
|
||||
pub special: bool,
|
||||
}
|
||||
|
||||
impl GeneratedCandidate {
|
||||
fn to_card(&self) -> GeneratedCard {
|
||||
GeneratedCard {
|
||||
card_id: self.card_id.clone(),
|
||||
rating: self.rating,
|
||||
position: self.position.clone(),
|
||||
nation: self.nation.clone(),
|
||||
league: self.league.clone(),
|
||||
club: self.club.clone(),
|
||||
attributes: self.attributes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One card a pack awarded. Carries `card_id` (the Core definition the host mints
|
||||
/// and shapes) plus the definition fields the shared item shaper needs. It is
|
||||
/// NOT an owned instance yet — the host mints the Core instance id and allocates
|
||||
/// the numeric wire id.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GeneratedCard {
|
||||
pub card_id: String,
|
||||
pub rating: u8,
|
||||
pub position: String,
|
||||
pub nation: String,
|
||||
pub league: String,
|
||||
pub club: String,
|
||||
pub attributes: [u8; 6],
|
||||
}
|
||||
|
||||
/// Draw `pack.count` cards from `pool` with the injected RNG. Pure and
|
||||
/// deterministic under a seeded RNG. Returns an empty `Vec` (fail-closed) when
|
||||
/// the pool is empty or the pack awards no cards.
|
||||
///
|
||||
/// Policy (PLACEHOLDER — see the module parity note): draw with replacement from
|
||||
/// the pack's tier (`gold`), biasing each draw toward a special card with
|
||||
/// probability `special_chance`. An empty tier or partition falls back to the
|
||||
/// next-wider set so a draw is always possible when the pool is non-empty.
|
||||
pub fn generate_pack_contents(
|
||||
pack: &PackDef,
|
||||
rng: &mut impl Rng,
|
||||
pool: &[GeneratedCandidate],
|
||||
) -> Vec<GeneratedCard> {
|
||||
if pool.is_empty() || pack.count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
// Tier split: a gold pack draws gold-tier candidates, a non-gold pack draws
|
||||
// non-gold; an empty tier falls back to the whole pool (oracle `... or POOL`).
|
||||
let tier: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.gold == pack.gold).collect();
|
||||
let tier: Vec<&GeneratedCandidate> = if tier.is_empty() {
|
||||
pool.iter().collect()
|
||||
} else {
|
||||
tier
|
||||
};
|
||||
// Partition the tier by special so `special_chance` can bias a draw; either
|
||||
// partition falls back to the whole tier when empty.
|
||||
let special: Vec<&GeneratedCandidate> = tier.iter().copied().filter(|c| c.special).collect();
|
||||
let normal: Vec<&GeneratedCandidate> = tier.iter().copied().filter(|c| !c.special).collect();
|
||||
let chance = pack.special_chance.clamp(0.0, 1.0);
|
||||
|
||||
let mut out = Vec::with_capacity(pack.count as usize);
|
||||
for _ in 0..pack.count {
|
||||
let want_special = chance > 0.0 && rng.gen_bool(chance);
|
||||
let sub: &[&GeneratedCandidate] = if want_special && !special.is_empty() {
|
||||
&special
|
||||
} else if !want_special && !normal.is_empty() {
|
||||
&normal
|
||||
} else {
|
||||
&tier
|
||||
};
|
||||
let pick = sub[rng.gen_range(0..sub.len())];
|
||||
out.push(pick.to_card());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rand::rngs::StdRng;
|
||||
use rand::SeedableRng;
|
||||
|
||||
fn cand(card: &str, rating: u8, gold: bool, special: bool) -> GeneratedCandidate {
|
||||
GeneratedCandidate {
|
||||
card_id: card.into(),
|
||||
rating,
|
||||
position: "ST".into(),
|
||||
nation: "Brazil".into(),
|
||||
league: "Premier League".into(),
|
||||
club: "Arsenal".into(),
|
||||
attributes: [rating; 6],
|
||||
gold,
|
||||
special,
|
||||
}
|
||||
}
|
||||
|
||||
/// A mixed pool: gold specials, gold normals, and a bronze tier.
|
||||
fn pool() -> Vec<GeneratedCandidate> {
|
||||
vec![
|
||||
cand("g-sp-1", 90, true, true),
|
||||
cand("g-sp-2", 88, true, true),
|
||||
cand("g-1", 84, true, false),
|
||||
cand("g-2", 82, true, false),
|
||||
cand("g-3", 79, true, false),
|
||||
cand("b-1", 64, false, false),
|
||||
cand("b-2", 62, false, false),
|
||||
]
|
||||
}
|
||||
|
||||
fn pack(id: u64, count: u64, gold: bool, special_chance: f64) -> PackDef {
|
||||
PackDef {
|
||||
id,
|
||||
name: "Test Pack",
|
||||
price: 1000,
|
||||
count,
|
||||
gold,
|
||||
special_chance,
|
||||
owned_only: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_seed_same_output() {
|
||||
let pool = pool();
|
||||
let p = pack(5, 7, true, 0.3);
|
||||
let mut a = StdRng::seed_from_u64(42);
|
||||
let mut b = StdRng::seed_from_u64(42);
|
||||
assert_eq!(
|
||||
generate_pack_contents(&p, &mut a, &pool),
|
||||
generate_pack_contents(&p, &mut b, &pool)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_seeds_can_diverge() {
|
||||
let pool = pool();
|
||||
let p = pack(5, 7, true, 0.3);
|
||||
let a = generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &pool);
|
||||
let b = generate_pack_contents(&p, &mut StdRng::seed_from_u64(999), &pool);
|
||||
// Not a hard guarantee, but with this pool/count the two seeds differ.
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_is_exact_and_all_cards_from_pool() {
|
||||
let pool = pool();
|
||||
let ids: std::collections::HashSet<&str> =
|
||||
pool.iter().map(|c| c.card_id.as_str()).collect();
|
||||
for &n in &[1u64, 5, 7, 11] {
|
||||
let p = pack(6, n, true, 0.08);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(n), &pool);
|
||||
assert_eq!(cards.len() as u64, n);
|
||||
for c in &cards {
|
||||
assert!(
|
||||
ids.contains(c.card_id.as_str()),
|
||||
"drew unknown card {}",
|
||||
c.card_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gold_pack_draws_only_gold_tier() {
|
||||
let pool = pool();
|
||||
let p = pack(5, 20, true, 0.03);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
|
||||
assert!(
|
||||
cards.iter().all(|c| c.rating >= 75),
|
||||
"gold pack drew a bronze card"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bronze_pack_draws_only_bronze_tier() {
|
||||
let pool = pool();
|
||||
let p = pack(1, 20, false, 0.005);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
|
||||
assert!(
|
||||
cards.iter().all(|c| c.rating < 75),
|
||||
"bronze pack drew a gold card"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_chance_one_draws_only_specials() {
|
||||
let pool = pool();
|
||||
let special_ids: std::collections::HashSet<&str> = pool
|
||||
.iter()
|
||||
.filter(|c| c.special)
|
||||
.map(|c| c.card_id.as_str())
|
||||
.collect();
|
||||
let p = pack(7, 11, true, 1.0);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(3), &pool);
|
||||
assert!(cards
|
||||
.iter()
|
||||
.all(|c| special_ids.contains(c.card_id.as_str())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pool_fails_closed() {
|
||||
let p = pack(5, 7, true, 0.03);
|
||||
assert!(generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &[]).is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user