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:
OpenFUT Agent
2026-08-13 20:47:57 +00:00
parent 0b31abe1d1
commit 4d2b8b9be3
10 changed files with 2835 additions and 0 deletions
+1
View File
@@ -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());
}
}