fix(fifa17): map market resource ids to authoritative Core card ids
Closes the market correctness gap: handle_market_list recorded listing.card_id from the raw FIFA wire resourceId, so a synthetic buy minted a card_id Core could not resolve — it survived the immediate response but Core's content preflight rejected it on reboot. - catalog.rs: keep the by_resource reverse index (was built then discarded) and expose `card_id_for_resource(resource_id) -> Option<&str>` — exact reverse of the card_id->asset catalog, no heuristics, unknown => None. - lib.rs: `impl MarketCardResolver for Fifa17IdentityResolver` delegates to the same catalog /club shaping uses; Core never sees a FIFA resource id. - market_store.rs: listings now carry BOTH `card_id` (authoritative Core content, what a buy MINTS) and `wire_resource_id` (the FIFA wire id, echoed in the auction record). New column; create_listing takes both; row/Listing updated. - market.rs: `MarketCardResolver` trait; handle_market_list resolves resourceId -> Core card_id and fails closed (persists nothing) on an unmappable resource; auction_record emits `resourceId` from wire_resource_id. Dispatch passes the resolver. Tests: list_unknown_resource_fails_closed_no_listing (B), list_persists_core_card_and_wire_resource_across_reopen (C), catalog reverse lookup; and the dispatch E2E now RESTORES the full Core+store restart (economy_full_sequence_through_dispatch_and_restart) — the synthetic buy mints a real reverse-mapped card_id, so Core's content preflight passes on reboot (A+D). market 23 lib + catalog 15 + 2 integration green; clippy -D warnings + fmt clean.
This commit is contained in:
@@ -125,6 +125,10 @@ fn default_rareflag() -> i64 {
|
|||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct Fifa17CardCatalog {
|
pub struct Fifa17CardCatalog {
|
||||||
by_card: HashMap<String, Fifa17CardIdentity>,
|
by_card: HashMap<String, Fifa17CardIdentity>,
|
||||||
|
/// Reverse index: full versioned `resource_id` → the card definition id. The
|
||||||
|
/// wire carries a `resourceId`; the synthetic market must mint the
|
||||||
|
/// authoritative Core `card_id`, never the raw FIFA number.
|
||||||
|
by_resource: HashMap<u32, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Fifa17CardCatalog {
|
impl Fifa17CardCatalog {
|
||||||
@@ -168,7 +172,10 @@ impl Fifa17CardCatalog {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(Fifa17CardCatalog { by_card })
|
Ok(Fifa17CardCatalog {
|
||||||
|
by_card,
|
||||||
|
by_resource,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a catalog from a JSON file.
|
/// Load a catalog from a JSON file.
|
||||||
@@ -183,6 +190,13 @@ impl Fifa17CardCatalog {
|
|||||||
self.by_card.get(card_id).copied()
|
self.by_card.get(card_id).copied()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reverse a FIFA wire `resource_id` (full versioned id) to its authoritative
|
||||||
|
/// Core `card_id`, or `None` (never a fabricated/heuristic id). Used by the
|
||||||
|
/// synthetic transfer market so a purchase mints real Core content.
|
||||||
|
pub fn card_id_for_resource(&self, resource_id: u32) -> Option<&str> {
|
||||||
|
self.by_resource.get(&resource_id).map(String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.by_card.len()
|
self.by_card.len()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -863,6 +863,17 @@ impl SquadWireResolver for Fifa17IdentityResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reverse a FIFA wire `resourceId` to the authoritative Core `card_id`, via the
|
||||||
|
/// same catalog `/club` shaping uses — so a synthetic market buy mints real Core
|
||||||
|
/// content, never the raw FIFA number. Out-of-range or unmapped → `None`
|
||||||
|
/// (fail closed; Core never sees a FIFA resource id).
|
||||||
|
impl crate::market::MarketCardResolver for Fifa17IdentityResolver {
|
||||||
|
fn card_id_for_resource(&self, resource_id: i64) -> Option<String> {
|
||||||
|
let rid = u32::try_from(resource_id).ok()?;
|
||||||
|
self.catalog.card_id_for_resource(rid).map(str::to_string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ───────────────────────────── /club handler ────────────────────────────────
|
// ───────────────────────────── /club handler ────────────────────────────────
|
||||||
|
|
||||||
/// Safe, structured summary of a handled `/club` request (no auth/session/device
|
/// Safe, structured summary of a handled `/club` request (no auth/session/device
|
||||||
@@ -1914,11 +1925,21 @@ impl Server {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
EconomyRoute::MarketList => {
|
EconomyRoute::MarketList => {
|
||||||
let (bridge, market, econ) =
|
let (bridge, market, econ, resolver) = (
|
||||||
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
svc.bridge.clone(),
|
||||||
|
svc.market.clone(),
|
||||||
|
svc.econ.clone(),
|
||||||
|
self.resolver.clone(),
|
||||||
|
);
|
||||||
let (m, body) = (method.to_string(), body.to_vec());
|
let (m, body) = (method.to_string(), body.to_vec());
|
||||||
bridge.block_on(async move {
|
bridge.block_on(async move {
|
||||||
crate::market::handle_market_list(&m, &body, econ.as_ref(), market.as_ref())
|
crate::market::handle_market_list(
|
||||||
|
&m,
|
||||||
|
&body,
|
||||||
|
econ.as_ref(),
|
||||||
|
market.as_ref(),
|
||||||
|
resolver.as_ref(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+122
-20
@@ -68,14 +68,9 @@ fn trade_id_from_path(path: &str) -> Option<String> {
|
|||||||
/// from durable listing state rather than a hardcoded sample pool.
|
/// from durable listing state rather than a hardcoded sample pool.
|
||||||
fn auction_record(l: &Listing) -> Value {
|
fn auction_record(l: &Listing) -> Value {
|
||||||
let trade_id: i64 = l.listing_id.parse().unwrap_or(0);
|
let trade_id: i64 = l.listing_id.parse().unwrap_or(0);
|
||||||
// resourceId is the card definition when numeric; fall back to the wire
|
// resourceId is the FIFA wire identity the client listed (never the Core
|
||||||
// item id. Never a fabricated FIFA asset — 0 means "no art", a valid int.
|
// card id). 0 means "no art", a valid int — never a fabricated FIFA asset.
|
||||||
let resource = l
|
let resource = l.wire_resource_id.or(l.wire_item_id).unwrap_or(0);
|
||||||
.card_id
|
|
||||||
.parse::<i64>()
|
|
||||||
.ok()
|
|
||||||
.or(l.wire_item_id)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let item_id = l.wire_item_id.unwrap_or(trade_id);
|
let item_id = l.wire_item_id.unwrap_or(trade_id);
|
||||||
let (trade_state, item_state, bid_state, current_bid) = match l.state.as_str() {
|
let (trade_state, item_state, bid_state, current_bid) = match l.state.as_str() {
|
||||||
"active" => ("active", "forSale", "none", 0),
|
"active" => ("active", "forSale", "none", 0),
|
||||||
@@ -124,18 +119,27 @@ fn credits_or_zero(econ: &dyn CoreEconomy) -> i64 {
|
|||||||
off_runtime(|| econ.balance()).unwrap_or(0)
|
off_runtime(|| econ.balance()).unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maps a FIFA wire `resourceId` to the authoritative Core `card_id` a synthetic
|
||||||
|
/// buy mints. Backed by the FIFA17 catalog reverse index; unknown → `None`
|
||||||
|
/// (fail closed, never fabricated). Core stays unaware of FIFA resource ids.
|
||||||
|
pub trait MarketCardResolver: Send + Sync {
|
||||||
|
fn card_id_for_resource(&self, resource_id: i64) -> Option<String>;
|
||||||
|
}
|
||||||
|
|
||||||
/// `/auctionhouse` — search (GET), list-for-sale (POST FutISStart), relist (PUT).
|
/// `/auctionhouse` — search (GET), list-for-sale (POST FutISStart), relist (PUT).
|
||||||
///
|
///
|
||||||
/// * GET returns the durable active auctions plus the FutGetAuctionCount ints,
|
/// * GET returns the durable active auctions plus the FutGetAuctionCount ints,
|
||||||
/// in the oracle's `_market_body` shape.
|
/// in the oracle's `_market_body` shape.
|
||||||
/// * POST lists an owned club item and returns `{"id": tradeId}`; the listing is
|
/// * POST lists a club item: resolve its wire `resourceId` to the authoritative
|
||||||
/// persisted so a later buy/cancel is durable.
|
/// Core `card_id`, persist both, and return `{"id": tradeId}`. An unmappable
|
||||||
|
/// resource fails closed (persists nothing).
|
||||||
/// * PUT (relist-all) is an ack `{}`.
|
/// * PUT (relist-all) is an ack `{}`.
|
||||||
pub async fn handle_market_list(
|
pub async fn handle_market_list(
|
||||||
method: &str,
|
method: &str,
|
||||||
body: &[u8],
|
body: &[u8],
|
||||||
econ: &dyn CoreEconomy,
|
econ: &dyn CoreEconomy,
|
||||||
store: &MarketStore,
|
store: &MarketStore,
|
||||||
|
mapper: &dyn MarketCardResolver,
|
||||||
) -> WireResponse {
|
) -> WireResponse {
|
||||||
match method {
|
match method {
|
||||||
"POST" => {
|
"POST" => {
|
||||||
@@ -151,13 +155,19 @@ pub async fn handle_market_list(
|
|||||||
// No item to list: mirror the oracle's fresh-id ack, persist nothing.
|
// No item to list: mirror the oracle's fresh-id ack, persist nothing.
|
||||||
return ok_json(&json!({ "id": TRADE_ID_BASE }));
|
return ok_json(&json!({ "id": TRADE_ID_BASE }));
|
||||||
};
|
};
|
||||||
// Card definition, if the client sent the full item; else the wire id
|
// Resolve the FIFA wire resourceId to the authoritative Core card id
|
||||||
// string (a user listing does not drive the synthetic-seller mint).
|
// the synthetic buy will MINT. Fail closed on an unmappable resource:
|
||||||
let card_id = item_data
|
// persist nothing (a non-existent listing cannot be bought), so a bad
|
||||||
|
// resource never becomes a mint Core's content preflight would reject.
|
||||||
|
let Some(resource_id) = item_data
|
||||||
.and_then(|d| d.get("resourceId"))
|
.and_then(|d| d.get("resourceId"))
|
||||||
.and_then(Value::as_i64)
|
.and_then(Value::as_i64)
|
||||||
.map(|r| r.to_string())
|
else {
|
||||||
.unwrap_or_else(|| item_id.to_string());
|
return ok_json(&json!({ "id": TRADE_ID_BASE }));
|
||||||
|
};
|
||||||
|
let Some(core_card_id) = mapper.card_id_for_resource(resource_id) else {
|
||||||
|
return ok_json(&json!({ "id": TRADE_ID_BASE }));
|
||||||
|
};
|
||||||
// Trade-id space is offset from the wire item id, so each owned item
|
// Trade-id space is offset from the wire item id, so each owned item
|
||||||
// maps to a unique, stable auction id (no modular wraparound).
|
// maps to a unique, stable auction id (no modular wraparound).
|
||||||
let trade_id = TRADE_ID_BASE + item_id;
|
let trade_id = TRADE_ID_BASE + item_id;
|
||||||
@@ -166,9 +176,10 @@ pub async fn handle_market_list(
|
|||||||
match store
|
match store
|
||||||
.create_listing(
|
.create_listing(
|
||||||
&listing_id,
|
&listing_id,
|
||||||
&card_id,
|
&core_card_id,
|
||||||
None,
|
None,
|
||||||
Some(item_id),
|
Some(item_id),
|
||||||
|
Some(resource_id),
|
||||||
start,
|
start,
|
||||||
buy_now,
|
buy_now,
|
||||||
seller,
|
seller,
|
||||||
@@ -514,6 +525,24 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Permissive test resolver: maps any wire resourceId to its own string, so
|
||||||
|
/// the existing list tests keep their prior card-id semantics. A dedicated
|
||||||
|
/// test covers the unknown-resource fail-closed path.
|
||||||
|
struct AllowAllResolver;
|
||||||
|
impl MarketCardResolver for AllowAllResolver {
|
||||||
|
fn card_id_for_resource(&self, resource_id: i64) -> Option<String> {
|
||||||
|
Some(resource_id.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test resolver that maps nothing (every resourceId is unknown).
|
||||||
|
struct DenyAllResolver;
|
||||||
|
impl MarketCardResolver for DenyAllResolver {
|
||||||
|
fn card_id_for_resource(&self, _resource_id: i64) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn store_at(tag: &str) -> (MarketStore, TempDb) {
|
async fn store_at(tag: &str) -> (MarketStore, TempDb) {
|
||||||
let db = TempDb::new(tag);
|
let db = TempDb::new(tag);
|
||||||
let store = MarketStore::open(db.path()).await.unwrap();
|
let store = MarketStore::open(db.path()).await.unwrap();
|
||||||
@@ -522,7 +551,7 @@ mod tests {
|
|||||||
|
|
||||||
async fn seed_listing(store: &MarketStore, id: &str, buy_now: i64) {
|
async fn seed_listing(store: &MarketStore, id: &str, buy_now: i64) {
|
||||||
store
|
store
|
||||||
.create_listing(id, "169193", None, None, 400, buy_now, None)
|
.create_listing(id, "169193", None, None, Some(169193), 400, buy_now, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
@@ -539,14 +568,21 @@ mod tests {
|
|||||||
let econ = CountingEconomy::with_balance(10_000);
|
let econ = CountingEconomy::with_balance(10_000);
|
||||||
let body = json!({ "itemData": { "id": 100004617, "resourceId": 169193 },
|
let body = json!({ "itemData": { "id": 100004617, "resourceId": 169193 },
|
||||||
"startingBid": 300, "buyNowPrice": 2500 });
|
"startingBid": 300, "buyNowPrice": 2500 });
|
||||||
let resp = handle_market_list("POST", body.to_string().as_bytes(), &econ, &store).await;
|
let resp = handle_market_list(
|
||||||
|
"POST",
|
||||||
|
body.to_string().as_bytes(),
|
||||||
|
&econ,
|
||||||
|
&store,
|
||||||
|
&AllowAllResolver,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert_eq!(resp.status, 200);
|
assert_eq!(resp.status, 200);
|
||||||
let trade_id = parse(&resp)["id"].as_i64().unwrap();
|
let trade_id = parse(&resp)["id"].as_i64().unwrap();
|
||||||
assert_eq!(trade_id, TRADE_ID_BASE + 100004617);
|
assert_eq!(trade_id, TRADE_ID_BASE + 100004617);
|
||||||
// Persisted + browsable.
|
// Persisted + browsable.
|
||||||
let listed = store.get_listing(&trade_id.to_string()).await.unwrap();
|
let listed = store.get_listing(&trade_id.to_string()).await.unwrap();
|
||||||
assert_eq!(listed.buy_now_price, 2500);
|
assert_eq!(listed.buy_now_price, 2500);
|
||||||
let browse = handle_market_list("GET", b"", &econ, &store).await;
|
let browse = handle_market_list("GET", b"", &econ, &store, &AllowAllResolver).await;
|
||||||
let b = parse(&browse);
|
let b = parse(&browse);
|
||||||
assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1);
|
assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1);
|
||||||
assert_eq!(b["credits"], 10_000);
|
assert_eq!(b["credits"], 10_000);
|
||||||
@@ -557,11 +593,77 @@ mod tests {
|
|||||||
async fn list_put_is_ack() {
|
async fn list_put_is_ack() {
|
||||||
let (store, _d) = store_at("put").await;
|
let (store, _d) = store_at("put").await;
|
||||||
let econ = CountingEconomy::with_balance(0);
|
let econ = CountingEconomy::with_balance(0);
|
||||||
let resp = handle_market_list("PUT", b"", &econ, &store).await;
|
let resp = handle_market_list("PUT", b"", &econ, &store, &AllowAllResolver).await;
|
||||||
assert_eq!(resp.status, 200);
|
assert_eq!(resp.status, 200);
|
||||||
assert_eq!(parse(&resp), json!({}));
|
assert_eq!(parse(&resp), json!({}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_unknown_resource_fails_closed_no_listing() {
|
||||||
|
// An unmappable wire resourceId must NOT create a listing (a synthetic buy
|
||||||
|
// would otherwise mint a card id Core cannot resolve). Acks neutrally.
|
||||||
|
let (store, _d) = store_at("deny").await;
|
||||||
|
let econ = CountingEconomy::with_balance(10_000);
|
||||||
|
let body = json!({ "itemData": { "id": 100004617, "resourceId": 424242 },
|
||||||
|
"startingBid": 300, "buyNowPrice": 2500 });
|
||||||
|
let resp = handle_market_list(
|
||||||
|
"POST",
|
||||||
|
body.to_string().as_bytes(),
|
||||||
|
&econ,
|
||||||
|
&store,
|
||||||
|
&DenyAllResolver,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(resp.status, 200);
|
||||||
|
assert_eq!(parse(&resp)["id"].as_i64().unwrap(), TRADE_ID_BASE);
|
||||||
|
// Nothing persisted at the would-be trade id: not buyable.
|
||||||
|
assert!(matches!(
|
||||||
|
store
|
||||||
|
.get_listing(&(TRADE_ID_BASE + 100004617).to_string())
|
||||||
|
.await,
|
||||||
|
Err(MarketError::NotFound)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_persists_core_card_and_wire_resource_across_reopen() {
|
||||||
|
// The listing carries BOTH the authoritative Core card_id (for the mint)
|
||||||
|
// and the FIFA wire resourceId (for the auction record), durably.
|
||||||
|
let db = TempDb::new("reopen-map");
|
||||||
|
let trade_id;
|
||||||
|
{
|
||||||
|
let store = MarketStore::open(db.path()).await.unwrap();
|
||||||
|
let econ = CountingEconomy::with_balance(10_000);
|
||||||
|
// A resolver that maps resourceId 20801 -> Core card_id "card_pl_042".
|
||||||
|
struct FixedResolver;
|
||||||
|
impl MarketCardResolver for FixedResolver {
|
||||||
|
fn card_id_for_resource(&self, resource_id: i64) -> Option<String> {
|
||||||
|
(resource_id == 20801).then(|| "card_pl_042".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let body = json!({ "itemData": { "id": 100004900, "resourceId": 20801 },
|
||||||
|
"startingBid": 300, "buyNowPrice": 2500 });
|
||||||
|
let resp = handle_market_list(
|
||||||
|
"POST",
|
||||||
|
body.to_string().as_bytes(),
|
||||||
|
&econ,
|
||||||
|
&store,
|
||||||
|
&FixedResolver,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
trade_id = parse(&resp)["id"].as_i64().unwrap();
|
||||||
|
}
|
||||||
|
// Reopen from the same file: both identities survive.
|
||||||
|
let reopened = MarketStore::open(db.path()).await.unwrap();
|
||||||
|
let l = reopened.get_listing(&trade_id.to_string()).await.unwrap();
|
||||||
|
assert_eq!(l.card_id, "card_pl_042", "Core card_id minted on buy");
|
||||||
|
assert_eq!(
|
||||||
|
l.wire_resource_id,
|
||||||
|
Some(20801),
|
||||||
|
"wire resourceId for the record"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn query_returns_active_pile() {
|
async fn query_returns_active_pile() {
|
||||||
let (store, _d) = store_at("query").await;
|
let (store, _d) = store_at("query").await;
|
||||||
|
|||||||
@@ -76,12 +76,17 @@ fn db(e: sqlx::Error) -> MarketError {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Listing {
|
pub struct Listing {
|
||||||
pub listing_id: String,
|
pub listing_id: String,
|
||||||
|
/// Authoritative Core content/card id — what a synthetic buy MINTS. Distinct
|
||||||
|
/// from the FIFA wire `resourceId` (see `wire_resource_id`).
|
||||||
pub card_id: String,
|
pub card_id: String,
|
||||||
/// Set for a seller-listed owned item; `None` for a synthetic-seller
|
/// Set for a seller-listed owned item; `None` for a synthetic-seller
|
||||||
/// listing (the buy path mints a fresh Core item instead of transferring).
|
/// listing (the buy path mints a fresh Core item instead of transferring).
|
||||||
pub core_item_id: Option<String>,
|
pub core_item_id: Option<String>,
|
||||||
/// The FIFA wire item id of a seller-listed owned item, if any.
|
/// The FIFA wire item id of a seller-listed owned item, if any.
|
||||||
pub wire_item_id: Option<i64>,
|
pub wire_item_id: Option<i64>,
|
||||||
|
/// The FIFA wire `resourceId` (versioned) the client listed, echoed back in
|
||||||
|
/// the auction record. Never used to mint — the mint uses `card_id`.
|
||||||
|
pub wire_resource_id: Option<i64>,
|
||||||
pub start_price: i64,
|
pub start_price: i64,
|
||||||
pub buy_now_price: i64,
|
pub buy_now_price: i64,
|
||||||
/// Opaque seller identity; `None` for synthetic listings.
|
/// Opaque seller identity; `None` for synthetic listings.
|
||||||
@@ -97,6 +102,7 @@ const CREATE_LISTINGS: &str = "CREATE TABLE IF NOT EXISTS listings (
|
|||||||
card_id TEXT NOT NULL,
|
card_id TEXT NOT NULL,
|
||||||
core_item_id TEXT,
|
core_item_id TEXT,
|
||||||
wire_item_id INTEGER,
|
wire_item_id INTEGER,
|
||||||
|
wire_resource_id INTEGER,
|
||||||
start_price INTEGER NOT NULL,
|
start_price INTEGER NOT NULL,
|
||||||
buy_now_price INTEGER NOT NULL,
|
buy_now_price INTEGER NOT NULL,
|
||||||
owner TEXT,
|
owner TEXT,
|
||||||
@@ -119,6 +125,7 @@ fn row_to_listing(row: &sqlx::sqlite::SqliteRow) -> Listing {
|
|||||||
card_id: row.get("card_id"),
|
card_id: row.get("card_id"),
|
||||||
core_item_id: row.get("core_item_id"),
|
core_item_id: row.get("core_item_id"),
|
||||||
wire_item_id: row.get("wire_item_id"),
|
wire_item_id: row.get("wire_item_id"),
|
||||||
|
wire_resource_id: row.get("wire_resource_id"),
|
||||||
start_price: row.get("start_price"),
|
start_price: row.get("start_price"),
|
||||||
buy_now_price: row.get("buy_now_price"),
|
buy_now_price: row.get("buy_now_price"),
|
||||||
owner: row.get("owner"),
|
owner: row.get("owner"),
|
||||||
@@ -177,6 +184,7 @@ impl MarketStore {
|
|||||||
card_id: &str,
|
card_id: &str,
|
||||||
core_item_id: Option<&str>,
|
core_item_id: Option<&str>,
|
||||||
wire_item_id: Option<i64>,
|
wire_item_id: Option<i64>,
|
||||||
|
wire_resource_id: Option<i64>,
|
||||||
start_price: i64,
|
start_price: i64,
|
||||||
buy_now_price: i64,
|
buy_now_price: i64,
|
||||||
owner: Option<&str>,
|
owner: Option<&str>,
|
||||||
@@ -189,13 +197,14 @@ impl MarketStore {
|
|||||||
.map_err(db)?;
|
.map_err(db)?;
|
||||||
let res = sqlx::query(
|
let res = sqlx::query(
|
||||||
"INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, \
|
"INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, \
|
||||||
start_price, buy_now_price, owner, state, created_at) \
|
wire_resource_id, start_price, buy_now_price, owner, state, created_at) \
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?)",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?)",
|
||||||
)
|
)
|
||||||
.bind(listing_id)
|
.bind(listing_id)
|
||||||
.bind(card_id)
|
.bind(card_id)
|
||||||
.bind(core_item_id)
|
.bind(core_item_id)
|
||||||
.bind(wire_item_id)
|
.bind(wire_item_id)
|
||||||
|
.bind(wire_resource_id)
|
||||||
.bind(start_price)
|
.bind(start_price)
|
||||||
.bind(buy_now_price)
|
.bind(buy_now_price)
|
||||||
.bind(owner)
|
.bind(owner)
|
||||||
@@ -213,6 +222,7 @@ impl MarketStore {
|
|||||||
card_id: card_id.to_string(),
|
card_id: card_id.to_string(),
|
||||||
core_item_id: core_item_id.map(str::to_string),
|
core_item_id: core_item_id.map(str::to_string),
|
||||||
wire_item_id,
|
wire_item_id,
|
||||||
|
wire_resource_id,
|
||||||
start_price,
|
start_price,
|
||||||
buy_now_price,
|
buy_now_price,
|
||||||
owner: owner.map(str::to_string),
|
owner: owner.map(str::to_string),
|
||||||
@@ -425,7 +435,7 @@ mod tests {
|
|||||||
|
|
||||||
async fn seed(store: &MarketStore, id: &str) -> Listing {
|
async fn seed(store: &MarketStore, id: &str) -> Listing {
|
||||||
store
|
store
|
||||||
.create_listing(id, "card_pl_001", None, None, 900, 2500, None)
|
.create_listing(id, "card_pl_001", None, None, None, 900, 2500, None)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
@@ -456,7 +466,7 @@ mod tests {
|
|||||||
seed(&store, "900000001").await;
|
seed(&store, "900000001").await;
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
store
|
store
|
||||||
.create_listing("900000001", "card_pl_002", None, None, 1, 2, None)
|
.create_listing("900000001", "card_pl_002", None, None, None, 1, 2, None)
|
||||||
.await,
|
.await,
|
||||||
Err(MarketError::Conflict)
|
Err(MarketError::Conflict)
|
||||||
));
|
));
|
||||||
@@ -516,6 +526,7 @@ mod tests {
|
|||||||
"card_pl_001",
|
"card_pl_001",
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
900,
|
900,
|
||||||
2500,
|
2500,
|
||||||
Some("alice"),
|
Some("alice"),
|
||||||
@@ -586,6 +597,7 @@ mod tests {
|
|||||||
"card_pl_001",
|
"card_pl_001",
|
||||||
Some("core-7"),
|
Some("core-7"),
|
||||||
Some(100004617),
|
Some(100004617),
|
||||||
|
Some(169193),
|
||||||
900,
|
900,
|
||||||
2500,
|
2500,
|
||||||
Some("alice"),
|
Some("alice"),
|
||||||
|
|||||||
@@ -259,8 +259,9 @@ async fn economy_end_to_end_and_restart_persistence() {
|
|||||||
// sequence runs on a plain OS thread (no ambient Tokio runtime), exactly like the
|
// sequence runs on a plain OS thread (no ambient Tokio runtime), exactly like the
|
||||||
// thread-per-connection server, so the bridge takes its DIRECT `block_on` path.
|
// thread-per-connection server, so the bridge takes its DIRECT `block_on` path.
|
||||||
|
|
||||||
/// Facts captured from the write sequence, re-checked after reopening the stores.
|
/// Facts captured from the write sequence, re-checked after a full restart.
|
||||||
struct SeqResult {
|
struct SeqResult {
|
||||||
|
final_balance: i64,
|
||||||
sold_listing: String,
|
sold_listing: String,
|
||||||
moved_core_id: String,
|
moved_core_id: String,
|
||||||
}
|
}
|
||||||
@@ -273,7 +274,7 @@ struct SeqResult {
|
|||||||
fn build_econ_server(
|
fn build_econ_server(
|
||||||
base: &str,
|
base: &str,
|
||||||
dir: &std::path::Path,
|
dir: &std::path::Path,
|
||||||
) -> (Server, HttpCoreClient, Arc<Fifa17IdentityResolver>) {
|
) -> (Server, HttpCoreClient, Arc<Fifa17IdentityResolver>, i64) {
|
||||||
let probe = HttpCoreClient::new(base, "fifa17");
|
let probe = HttpCoreClient::new(base, "fifa17");
|
||||||
let owned = probe.all_owned().expect("core collection");
|
let owned = probe.all_owned().expect("core collection");
|
||||||
assert!(!owned.is_empty(), "seed must grant a starter collection");
|
assert!(!owned.is_empty(), "seed must grant a starter collection");
|
||||||
@@ -337,7 +338,9 @@ fn build_econ_server(
|
|||||||
33068179,
|
33068179,
|
||||||
)
|
)
|
||||||
.with_economy(services);
|
.with_economy(services);
|
||||||
(server, probe, resolver)
|
// asset 20000 is assigned to the first distinct seeded definition, so wire
|
||||||
|
// resourceId 20000 reverse-maps to a real Core card_id (a valid synthetic mint).
|
||||||
|
(server, probe, resolver, 20000)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drive the whole writer+reader cluster through the real dispatch. Panics on any
|
/// Drive the whole writer+reader cluster through the real dispatch. Panics on any
|
||||||
@@ -346,7 +349,7 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
|||||||
wait_ready(base);
|
wait_ready(base);
|
||||||
// Core is seeded (start_core_seeded): a fifa17 profile with 100k coins + one
|
// Core is seeded (start_core_seeded): a fifa17 profile with 100k coins + one
|
||||||
// owned instance per definition. No /auth/local — the profile already exists.
|
// owned instance per definition. No /auth/local — the profile already exists.
|
||||||
let (server, client, resolver) = build_econ_server(base, dir);
|
let (server, client, resolver, sample_resource) = build_econ_server(base, dir);
|
||||||
let start = client.balance().unwrap();
|
let start = client.balance().unwrap();
|
||||||
assert!(start >= 5000, "seeded dev balance present ({start})");
|
assert!(start >= 5000, "seeded dev balance present ({start})");
|
||||||
|
|
||||||
@@ -440,7 +443,10 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
|||||||
"POST",
|
"POST",
|
||||||
"/ut/game/fifa17/auctionhouse",
|
"/ut/game/fifa17/auctionhouse",
|
||||||
&[],
|
&[],
|
||||||
br#"{"itemData":{"id":777,"resourceId":777},"buyNowPrice":1000,"startingBid":500}"#,
|
format!(
|
||||||
|
r#"{{"itemData":{{"id":777,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"#
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.expect("market list routed");
|
.expect("market list routed");
|
||||||
@@ -501,7 +507,10 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
|||||||
"POST",
|
"POST",
|
||||||
"/ut/game/fifa17/auctionhouse",
|
"/ut/game/fifa17/auctionhouse",
|
||||||
&[],
|
&[],
|
||||||
br#"{"itemData":{"id":888,"resourceId":888},"buyNowPrice":1000,"startingBid":500}"#,
|
format!(
|
||||||
|
r#"{{"itemData":{{"id":888,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"#
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -559,17 +568,25 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
|||||||
.expect("moved item reverses to a Core id");
|
.expect("moved item reverses to a Core id");
|
||||||
|
|
||||||
SeqResult {
|
SeqResult {
|
||||||
|
final_balance: client.balance().unwrap(),
|
||||||
sold_listing: trade_id.to_string(),
|
sold_listing: trade_id.to_string(),
|
||||||
moved_core_id,
|
moved_core_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reopen the durable market/pile SQLite stores from the SAME files (a fresh
|
/// After a FULL restart from the SAME on-disk state — Core rebooted from its
|
||||||
/// process would do exactly this) and prove the sold listing and the pile move
|
/// SQLite file, and the durable market/pile stores reopened from their files —
|
||||||
/// persisted. Core-side coin/inventory persistence across a full Core restart is
|
/// coins, the sold listing, and the pile move all persist. The synthetic market
|
||||||
/// proven by `economy_end_to_end_and_restart_persistence`; here the focus is the
|
/// buy now mints a REAL Core `card_id` (resourceId reverse-mapped), so Core's
|
||||||
/// host-owned durable stores.
|
/// content preflight passes on reboot.
|
||||||
fn verify_store_durability(dir: &std::path::Path, seq: &SeqResult) {
|
fn verify_economy_restart(base: &str, dir: &std::path::Path, seq: &SeqResult) {
|
||||||
|
wait_ready(base);
|
||||||
|
let client = HttpCoreClient::new(base, "fifa17");
|
||||||
|
assert_eq!(
|
||||||
|
client.balance().unwrap(),
|
||||||
|
seq.final_balance,
|
||||||
|
"coins persisted across Core restart"
|
||||||
|
);
|
||||||
let bridge = AsyncBridge::new().unwrap();
|
let bridge = AsyncBridge::new().unwrap();
|
||||||
let market_path = dir.join("market.db").to_string_lossy().into_owned();
|
let market_path = dir.join("market.db").to_string_lossy().into_owned();
|
||||||
let market = bridge
|
let market = bridge
|
||||||
@@ -598,7 +615,7 @@ fn verify_store_durability(dir: &std::path::Path, seq: &SeqResult) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
async fn economy_full_sequence_through_dispatch() {
|
async fn economy_full_sequence_through_dispatch_and_restart() {
|
||||||
let dir = std::env::temp_dir().join(format!(
|
let dir = std::env::temp_dir().join(format!(
|
||||||
"openfut-econ-dispatch-{}-{}",
|
"openfut-econ-dispatch-{}-{}",
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
@@ -622,18 +639,18 @@ async fn economy_full_sequence_through_dispatch() {
|
|||||||
.expect("write sequence");
|
.expect("write sequence");
|
||||||
h1.abort();
|
h1.abort();
|
||||||
|
|
||||||
// Durable host stores: reopen the market/pile files from disk (as a fresh
|
// Core #2: same on-disk Core DB + same market/pile files — prove full restart
|
||||||
// process would) and prove sold/pile state persisted. No Core rebuild — the
|
// persistence (coins + sold listing + pile). Core content preflight passes
|
||||||
// synthetic market mint uses the wire resourceId as a placeholder card id,
|
// because the synthetic mint used a real reverse-mapped card_id.
|
||||||
// which Core's content preflight (correctly) rejects on reboot; Core-side
|
let (h2, base2) = start_core_seeded(&db_url, false).await;
|
||||||
// coin/inventory restart persistence is covered by the sibling test.
|
let (b2, d2) = (base2.clone(), dir.clone());
|
||||||
let d2 = dir.clone();
|
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let t = std::thread::spawn(move || verify_store_durability(&d2, &seq));
|
let t = std::thread::spawn(move || verify_economy_restart(&b2, &d2, &seq));
|
||||||
t.join().expect("durability thread")
|
t.join().expect("restart thread")
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("durability phase");
|
.expect("restart phase");
|
||||||
|
h2.abort();
|
||||||
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user