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
+608
View File
@@ -0,0 +1,608 @@
//! Durable FIFA 17 transfer-market **listing** store.
//!
//! This is host-owned FIFA policy state, NOT generic Core inventory. Core stays
//! the sole authority for coins and item ownership; the market layer owns only
//! the durable *listing* lifecycle (who is selling what, at what price, and in
//! which state). It is backed by its own SQLite file so it survives restart.
//!
//! ## Concurrency model (load-bearing)
//!
//! The pool is opened exactly like [`openfut_core::db::init_pool`]: WAL is
//! established once on the file before the pool opens, every pooled connection
//! carries `foreign_keys=ON` and a 5s `busy_timeout`, and **every write runs
//! inside a `BEGIN IMMEDIATE` transaction**. Immediate transactions take the
//! write lock up front, so the reserve compare-and-swap is genuinely atomic
//! across connections — the exact class of bug (deferred transactions racing a
//! read-then-write) that was just fixed in Core. Two buyers reserving the same
//! active listing therefore resolve to exactly one winner.
//!
//! ## State machine
//!
//! ```text
//! active ──reserve──▶ reserved ──complete_sale──▶ sold
//! │ │
//! │ └──rollback_reservation──▶ active
//! └──cancel──▶ cancelled
//! ```
//!
//! `reserved` is a real state (a listing being paid for), so it is part of the
//! `CHECK` constraint even though it is a transient intermediate — omitting it
//! would make [`MarketStore::reserve_listing`] fail the constraint.
use std::time::Duration;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use sqlx::{ConnectOptions, Connection, Row, SqlitePool};
/// Typed failure of a listing operation. `Db` wraps an infrastructure error
/// (transport/encoding); everything else is a modelled lifecycle outcome.
#[derive(Debug)]
pub enum MarketError {
/// No listing with that id exists.
NotFound,
/// The listing has already been sold.
Sold,
/// The listing has already been cancelled.
Cancelled,
/// The caller is not the owner of the listing.
WrongOwner,
/// The listing was not in the state the transition required (e.g. a
/// reserved listing asked to cancel, or a duplicate id on insert).
Conflict,
/// SQLite / transport failure.
Db(String),
}
impl std::fmt::Display for MarketError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MarketError::NotFound => write!(f, "listing not found"),
MarketError::Sold => write!(f, "listing already sold"),
MarketError::Cancelled => write!(f, "listing already cancelled"),
MarketError::WrongOwner => write!(f, "listing owned by another seller"),
MarketError::Conflict => write!(f, "listing state conflict"),
MarketError::Db(e) => write!(f, "market store db error: {e}"),
}
}
}
impl std::error::Error for MarketError {}
fn db(e: sqlx::Error) -> MarketError {
MarketError::Db(e.to_string())
}
/// One transfer-market listing row.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Listing {
pub listing_id: String,
pub card_id: String,
/// Set for a seller-listed owned item; `None` for a synthetic-seller
/// listing (the buy path mints a fresh Core item instead of transferring).
pub core_item_id: Option<String>,
/// The FIFA wire item id of a seller-listed owned item, if any.
pub wire_item_id: Option<i64>,
pub start_price: i64,
pub buy_now_price: i64,
/// Opaque seller identity; `None` for synthetic listings.
pub owner: Option<String>,
/// `active` | `reserved` | `sold` | `cancelled`.
pub state: String,
/// Creation time, unix-epoch milliseconds as a string (sortable).
pub created_at: String,
}
const CREATE_LISTINGS: &str = "CREATE TABLE IF NOT EXISTS listings (
listing_id TEXT PRIMARY KEY,
card_id TEXT NOT NULL,
core_item_id TEXT,
wire_item_id INTEGER,
start_price INTEGER NOT NULL,
buy_now_price INTEGER NOT NULL,
owner TEXT,
state TEXT NOT NULL CHECK (state IN ('active','reserved','sold','cancelled')),
created_at TEXT NOT NULL
)";
fn now_millis() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
.to_string()
}
fn row_to_listing(row: &sqlx::sqlite::SqliteRow) -> Listing {
Listing {
listing_id: row.get("listing_id"),
card_id: row.get("card_id"),
core_item_id: row.get("core_item_id"),
wire_item_id: row.get("wire_item_id"),
start_price: row.get("start_price"),
buy_now_price: row.get("buy_now_price"),
owner: row.get("owner"),
state: row.get("state"),
created_at: row.get("created_at"),
}
}
/// Durable listing store over an sqlx SQLite pool. Cheap to clone (the pool is
/// an `Arc` internally), so the same store can be shared across tasks.
#[derive(Clone)]
pub struct MarketStore {
pool: SqlitePool,
}
impl MarketStore {
/// Open (creating if missing) the market DB at `path`, mirroring Core's
/// `init_pool`: establish WAL once on the file, then open a multi-connection
/// pool where every connection carries foreign_keys + a busy_timeout.
pub async fn open(path: &str) -> Result<Self, MarketError> {
let opts = SqliteConnectOptions::new()
.filename(path)
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.foreign_keys(true)
.busy_timeout(Duration::from_secs(5));
// Establish WAL on the file via ONE connection BEFORE the pool opens, so
// pooled connections only ever re-assert an already-WAL file (see Core).
{
let mut conn = opts.clone().connect().await.map_err(db)?;
sqlx::query("PRAGMA journal_mode=WAL")
.execute(&mut conn)
.await
.map_err(db)?;
conn.close().await.map_err(db)?;
}
let pool = SqlitePoolOptions::new()
.max_connections(8)
.connect_with(opts)
.await
.map_err(db)?;
sqlx::query(CREATE_LISTINGS)
.execute(&pool)
.await
.map_err(db)?;
Ok(MarketStore { pool })
}
/// Insert a new `active` listing. `listing_id` is the numeric-string trade id
/// the client keys the auction on (the caller allocates it). Duplicate id ->
/// [`MarketError::Conflict`].
#[allow(clippy::too_many_arguments)]
pub async fn create_listing(
&self,
listing_id: &str,
card_id: &str,
core_item_id: Option<&str>,
wire_item_id: Option<i64>,
start_price: i64,
buy_now_price: i64,
owner: Option<&str>,
) -> Result<Listing, MarketError> {
let created_at = now_millis();
let mut conn = self.pool.acquire().await.map_err(db)?;
sqlx::query("BEGIN IMMEDIATE")
.execute(&mut *conn)
.await
.map_err(db)?;
let res = sqlx::query(
"INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, \
start_price, buy_now_price, owner, state, created_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?)",
)
.bind(listing_id)
.bind(card_id)
.bind(core_item_id)
.bind(wire_item_id)
.bind(start_price)
.bind(buy_now_price)
.bind(owner)
.bind(&created_at)
.execute(&mut *conn)
.await;
match res {
Ok(_) => {
sqlx::query("COMMIT")
.execute(&mut *conn)
.await
.map_err(db)?;
Ok(Listing {
listing_id: listing_id.to_string(),
card_id: card_id.to_string(),
core_item_id: core_item_id.map(str::to_string),
wire_item_id,
start_price,
buy_now_price,
owner: owner.map(str::to_string),
state: "active".to_string(),
created_at,
})
}
Err(e) => {
let _ = sqlx::query("ROLLBACK").execute(&mut *conn).await;
// A PK clash is a caller-level conflict, not an infra failure.
if matches!(&e, sqlx::Error::Database(dbe) if dbe.is_unique_violation()) {
Err(MarketError::Conflict)
} else {
Err(db(e))
}
}
}
}
/// Fetch one listing, or [`MarketError::NotFound`].
pub async fn get_listing(&self, listing_id: &str) -> Result<Listing, MarketError> {
let row = sqlx::query("SELECT * FROM listings WHERE listing_id = ?")
.bind(listing_id)
.fetch_optional(&self.pool)
.await
.map_err(db)?;
row.as_ref()
.map(row_to_listing)
.ok_or(MarketError::NotFound)
}
/// All listings in `state`, oldest first.
pub async fn query_listings(&self, state: &str) -> Result<Vec<Listing>, MarketError> {
let rows = sqlx::query("SELECT * FROM listings WHERE state = ? ORDER BY created_at ASC")
.bind(state)
.fetch_all(&self.pool)
.await
.map_err(db)?;
Ok(rows.iter().map(row_to_listing).collect())
}
/// Atomic compare-and-swap of a single listing's state inside a
/// `BEGIN IMMEDIATE` transaction. `Ok(true)` = the row was in `from` and is
/// now `to`; `Ok(false)` = the row exists but was not in `from` (lost race /
/// wrong state); `Err(NotFound)` = no such row.
async fn cas(&self, listing_id: &str, from: &str, to: &str) -> Result<bool, MarketError> {
let mut conn = self.pool.acquire().await.map_err(db)?;
sqlx::query("BEGIN IMMEDIATE")
.execute(&mut *conn)
.await
.map_err(db)?;
let outcome: Result<bool, MarketError> = async {
let current: Option<String> =
sqlx::query("SELECT state FROM listings WHERE listing_id = ?")
.bind(listing_id)
.fetch_optional(&mut *conn)
.await
.map_err(db)?
.map(|r| r.get::<String, _>("state"));
match current {
None => Err(MarketError::NotFound),
Some(s) if s == from => {
sqlx::query("UPDATE listings SET state = ? WHERE listing_id = ? AND state = ?")
.bind(to)
.bind(listing_id)
.bind(from)
.execute(&mut *conn)
.await
.map_err(db)?;
Ok(true)
}
Some(_) => Ok(false),
}
}
.await;
match &outcome {
Ok(_) => {
sqlx::query("COMMIT")
.execute(&mut *conn)
.await
.map_err(db)?;
}
Err(_) => {
let _ = sqlx::query("ROLLBACK").execute(&mut *conn).await;
}
}
outcome
}
/// Reserve an `active` listing (`active -> reserved`). Returns whether this
/// caller won the reservation. Exactly one of two concurrent callers wins.
pub async fn reserve_listing(&self, listing_id: &str) -> Result<bool, MarketError> {
self.cas(listing_id, "active", "reserved").await
}
/// Finalise a won reservation (`reserved -> sold`). A listing not in
/// `reserved` is a [`MarketError::Conflict`].
pub async fn complete_sale(&self, listing_id: &str) -> Result<(), MarketError> {
if self.cas(listing_id, "reserved", "sold").await? {
Ok(())
} else {
Err(MarketError::Conflict)
}
}
/// Undo a reservation on a downstream failure (`reserved -> active`), so the
/// listing becomes buyable again. Not in `reserved` -> [`MarketError::Conflict`].
pub async fn rollback_reservation(&self, listing_id: &str) -> Result<(), MarketError> {
if self.cas(listing_id, "reserved", "active").await? {
Ok(())
} else {
Err(MarketError::Conflict)
}
}
/// Cancel an `active` listing once (`active -> cancelled`). If `owner` is
/// supplied it must match the listing's owner. Returns typed errors for
/// every non-active state so a double cancel is observable.
pub async fn cancel_listing(
&self,
listing_id: &str,
owner: Option<&str>,
) -> Result<(), MarketError> {
let mut conn = self.pool.acquire().await.map_err(db)?;
sqlx::query("BEGIN IMMEDIATE")
.execute(&mut *conn)
.await
.map_err(db)?;
let outcome: Result<(), MarketError> = async {
let row = sqlx::query("SELECT state, owner FROM listings WHERE listing_id = ?")
.bind(listing_id)
.fetch_optional(&mut *conn)
.await
.map_err(db)?;
let row = row.ok_or(MarketError::NotFound)?;
let state: String = row.get("state");
let stored_owner: Option<String> = row.get("owner");
if let Some(want) = owner {
if stored_owner.as_deref() != Some(want) {
return Err(MarketError::WrongOwner);
}
}
match state.as_str() {
"active" => {
sqlx::query(
"UPDATE listings SET state = 'cancelled' \
WHERE listing_id = ? AND state = 'active'",
)
.bind(listing_id)
.execute(&mut *conn)
.await
.map_err(db)?;
Ok(())
}
"sold" => Err(MarketError::Sold),
"cancelled" => Err(MarketError::Cancelled),
_ => Err(MarketError::Conflict),
}
}
.await;
match &outcome {
Ok(_) => {
sqlx::query("COMMIT")
.execute(&mut *conn)
.await
.map_err(db)?;
}
Err(_) => {
let _ = sqlx::query("ROLLBACK").execute(&mut *conn).await;
}
}
outcome
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
/// A unique temp DB path that deletes its file (and WAL/SHM sidecars) on
/// drop. Holding the guard keeps the file alive across store reopens.
struct TempDb(String);
impl TempDb {
fn new() -> Self {
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::SeqCst);
let path =
std::env::temp_dir().join(format!("ofut-market-{}-{n}.db", std::process::id()));
TempDb(path.to_string_lossy().into_owned())
}
fn path(&self) -> &str {
&self.0
}
}
impl Drop for TempDb {
fn drop(&mut self) {
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(format!("{}{suffix}", self.0));
}
}
}
async fn temp_store() -> (MarketStore, TempDb) {
let db = TempDb::new();
let store = MarketStore::open(db.path()).await.unwrap();
(store, db)
}
async fn seed(store: &MarketStore, id: &str) -> Listing {
store
.create_listing(id, "card_pl_001", None, None, 900, 2500, None)
.await
.unwrap()
}
#[tokio::test]
async fn create_get_query_roundtrip() {
let (store, _d) = temp_store().await;
let created = seed(&store, "900000001").await;
assert_eq!(created.state, "active");
assert_eq!(created.buy_now_price, 2500);
let got = store.get_listing("900000001").await.unwrap();
assert_eq!(got, created);
assert!(matches!(
store.get_listing("nope").await,
Err(MarketError::NotFound)
));
let active = store.query_listings("active").await.unwrap();
assert_eq!(active.len(), 1);
assert!(store.query_listings("sold").await.unwrap().is_empty());
}
#[tokio::test]
async fn duplicate_id_is_conflict() {
let (store, _d) = temp_store().await;
seed(&store, "900000001").await;
assert!(matches!(
store
.create_listing("900000001", "card_pl_002", None, None, 1, 2, None)
.await,
Err(MarketError::Conflict)
));
}
#[tokio::test]
async fn reserve_complete_lifecycle() {
let (store, _d) = temp_store().await;
seed(&store, "900000001").await;
assert!(store.reserve_listing("900000001").await.unwrap());
// Second reserve of a now-reserved listing loses.
assert!(!store.reserve_listing("900000001").await.unwrap());
store.complete_sale("900000001").await.unwrap();
assert_eq!(store.get_listing("900000001").await.unwrap().state, "sold");
// Completing again (not reserved) is a conflict.
assert!(matches!(
store.complete_sale("900000001").await,
Err(MarketError::Conflict)
));
}
#[tokio::test]
async fn rollback_restores_active() {
let (store, _d) = temp_store().await;
seed(&store, "900000001").await;
assert!(store.reserve_listing("900000001").await.unwrap());
store.rollback_reservation("900000001").await.unwrap();
assert_eq!(
store.get_listing("900000001").await.unwrap().state,
"active"
);
// Buyable again after rollback.
assert!(store.reserve_listing("900000001").await.unwrap());
}
#[tokio::test]
async fn cancel_once_then_errors() {
let (store, _d) = temp_store().await;
seed(&store, "900000001").await;
store.cancel_listing("900000001", None).await.unwrap();
assert_eq!(
store.get_listing("900000001").await.unwrap().state,
"cancelled"
);
assert!(matches!(
store.cancel_listing("900000001", None).await,
Err(MarketError::Cancelled)
));
}
#[tokio::test]
async fn cancel_checks_owner() {
let (store, _d) = temp_store().await;
store
.create_listing(
"900000001",
"card_pl_001",
None,
None,
900,
2500,
Some("alice"),
)
.await
.unwrap();
assert!(matches!(
store.cancel_listing("900000001", Some("mallory")).await,
Err(MarketError::WrongOwner)
));
store
.cancel_listing("900000001", Some("alice"))
.await
.unwrap();
assert_eq!(
store.get_listing("900000001").await.unwrap().state,
"cancelled"
);
}
#[tokio::test]
async fn cannot_reserve_sold_or_cancelled() {
let (store, _d) = temp_store().await;
seed(&store, "sold_one").await;
store.reserve_listing("sold_one").await.unwrap();
store.complete_sale("sold_one").await.unwrap();
assert!(!store.reserve_listing("sold_one").await.unwrap());
seed(&store, "cancel_one").await;
store.cancel_listing("cancel_one", None).await.unwrap();
assert!(!store.reserve_listing("cancel_one").await.unwrap());
}
/// Two tasks reserve the SAME active listing concurrently -> exactly one wins.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn two_reservers_exactly_one_wins() {
let (store, _d) = temp_store().await;
seed(&store, "900000001").await;
let store = Arc::new(store);
let a = {
let s = store.clone();
tokio::spawn(async move { s.reserve_listing("900000001").await.unwrap() })
};
let b = {
let s = store.clone();
tokio::spawn(async move { s.reserve_listing("900000001").await.unwrap() })
};
let (ra, rb) = (a.await.unwrap(), b.await.unwrap());
assert_ne!(ra, rb, "exactly one reserver must win");
assert!(ra || rb, "one reserver must win");
assert_eq!(
store.get_listing("900000001").await.unwrap().state,
"reserved"
);
}
/// State survives closing and reopening the store file (durable, not in-memory).
#[tokio::test]
async fn state_survives_reopen() {
let db = TempDb::new();
let path = db.path();
{
let store = MarketStore::open(path).await.unwrap();
store
.create_listing(
"900000001",
"card_pl_001",
Some("core-7"),
Some(100004617),
900,
2500,
Some("alice"),
)
.await
.unwrap();
store
.cancel_listing("900000001", Some("alice"))
.await
.unwrap();
// pool dropped at end of scope
}
let reopened = MarketStore::open(path).await.unwrap();
let got = reopened.get_listing("900000001").await.unwrap();
assert_eq!(got.state, "cancelled");
assert_eq!(got.core_item_id.as_deref(), Some("core-7"));
assert_eq!(got.wire_item_id, Some(100004617));
assert_eq!(got.owner.as_deref(), Some("alice"));
}
}