feat(market): PHASE C — expose every unlisted trade-pile item as tradeState "inactive"
Q2 is LIVE-CONFIRMED (operator saw the inactive row under TRANSFER LIST with Start Price 0 and no Buy Now / Current Bid / timer, active rows still separate under LISTED ITEMS, and the state survived a full FUT exit/re-entry). Promoting from the bounded one-item probe to the real behaviour: the env gate is gone and /tradePile now enumerates the whole trade pile. Mechanism: read the pile (async), resolve each member to a shaped card (sync, because the identity/Core resolvers are not `Send`), then build the response (async). The core->wire lookup is `wire_for_owned_id`, which uses the identity store's NON-allocating `external_for` -- enumerating a pile is a READ and must never mint a wire id for an item the client has not seen. Items with no mapping, no Core record or no resolvable FIFA identity are skipped, never faked. Includes a bug the DIFFERENTIAL caught and unit tests did not: a pile row OUTLIVES its auction, so after a sale the seller's `trade` row is stale, and filtering only on ACTIVE listings re-advertised a SOLD card as an owned unlisted item. Suppression is now by listing state via `blocking_core_items()` -- active (real auction shown instead), reserved (sale in flight) and sold (card gone) -- while `cancelled` is deliberately NOT suppressed, because a cancelled listing means the card came back to the pile. New test covers all three plus the store-level rule. counts semantics deliberately unchanged: `count`/`selling` still track auctions only. 341 tests pass, 0 failed, clippy clean. Deployed: the 6 previously stranded pile items now render, alongside the 1 active listing, with Ronaldo correctly in /club and out of the pile. Body preserved as phase-c-full-pile-exposed.json.
This commit is contained in:
@@ -0,0 +1,434 @@
|
|||||||
|
{
|
||||||
|
"auctionInfo": [
|
||||||
|
{
|
||||||
|
"bidState": "none",
|
||||||
|
"buyNowPrice": 15000,
|
||||||
|
"coinsProcessed": 0,
|
||||||
|
"currentBid": 0,
|
||||||
|
"expires": 1474,
|
||||||
|
"itemData": {
|
||||||
|
"assetId": 158023,
|
||||||
|
"attributeList": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"value": 89
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"value": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 2,
|
||||||
|
"value": 86
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 3,
|
||||||
|
"value": 96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 4,
|
||||||
|
"value": 26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 5,
|
||||||
|
"value": 61
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cardassetid": 158023,
|
||||||
|
"cardsubtypeid": 0,
|
||||||
|
"contract": 7,
|
||||||
|
"definitionId": 158023,
|
||||||
|
"discardValue": 1500,
|
||||||
|
"fitness": 99,
|
||||||
|
"id": 100000155,
|
||||||
|
"itemState": "listFS",
|
||||||
|
"itemType": "player",
|
||||||
|
"leagueId": 53,
|
||||||
|
"nation": 52,
|
||||||
|
"owners": 1,
|
||||||
|
"playStyle": 250,
|
||||||
|
"preferredPosition": "RW",
|
||||||
|
"rareflag": 1,
|
||||||
|
"rating": 93,
|
||||||
|
"resourceId": 158023,
|
||||||
|
"teamid": 241,
|
||||||
|
"untradeable": false
|
||||||
|
},
|
||||||
|
"sellerEstablished": 1,
|
||||||
|
"sellerName": "CAGE",
|
||||||
|
"startingBid": 150,
|
||||||
|
"tradeId": 1000000155,
|
||||||
|
"tradeState": "active",
|
||||||
|
"watched": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bidState": "none",
|
||||||
|
"buyNowPrice": 0,
|
||||||
|
"coinsProcessed": 0,
|
||||||
|
"currentBid": 0,
|
||||||
|
"expires": 0,
|
||||||
|
"itemData": {
|
||||||
|
"assetId": 172610,
|
||||||
|
"attributeList": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"value": 69
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"value": 33
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 2,
|
||||||
|
"value": 47
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 3,
|
||||||
|
"value": 57
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 4,
|
||||||
|
"value": 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 5,
|
||||||
|
"value": 76
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cardassetid": 172610,
|
||||||
|
"cardsubtypeid": 0,
|
||||||
|
"contract": 7,
|
||||||
|
"definitionId": 172610,
|
||||||
|
"discardValue": 900,
|
||||||
|
"fitness": 99,
|
||||||
|
"id": 100004867,
|
||||||
|
"itemState": "free",
|
||||||
|
"itemType": "player",
|
||||||
|
"leagueId": 31,
|
||||||
|
"nation": 54,
|
||||||
|
"owners": 1,
|
||||||
|
"playStyle": 250,
|
||||||
|
"preferredPosition": "CB",
|
||||||
|
"rareflag": 1,
|
||||||
|
"rating": 80,
|
||||||
|
"resourceId": 172610,
|
||||||
|
"teamid": 54,
|
||||||
|
"untradeable": false
|
||||||
|
},
|
||||||
|
"sellerEstablished": 1,
|
||||||
|
"sellerName": "CAGE",
|
||||||
|
"startingBid": 0,
|
||||||
|
"tradeId": 1000004867,
|
||||||
|
"tradeState": "inactive",
|
||||||
|
"watched": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bidState": "none",
|
||||||
|
"buyNowPrice": 0,
|
||||||
|
"coinsProcessed": 0,
|
||||||
|
"currentBid": 0,
|
||||||
|
"expires": 0,
|
||||||
|
"itemData": {
|
||||||
|
"assetId": 163824,
|
||||||
|
"attributeList": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"value": 70
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"value": 43
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 2,
|
||||||
|
"value": 56
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 3,
|
||||||
|
"value": 62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 4,
|
||||||
|
"value": 82
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 5,
|
||||||
|
"value": 82
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cardassetid": 163824,
|
||||||
|
"cardsubtypeid": 0,
|
||||||
|
"contract": 7,
|
||||||
|
"definitionId": 163824,
|
||||||
|
"discardValue": 900,
|
||||||
|
"fitness": 99,
|
||||||
|
"id": 100004866,
|
||||||
|
"itemState": "free",
|
||||||
|
"itemType": "player",
|
||||||
|
"leagueId": 13,
|
||||||
|
"nation": 50,
|
||||||
|
"owners": 1,
|
||||||
|
"playStyle": 250,
|
||||||
|
"preferredPosition": "CB",
|
||||||
|
"rareflag": 1,
|
||||||
|
"rating": 83,
|
||||||
|
"resourceId": 163824,
|
||||||
|
"teamid": 7,
|
||||||
|
"untradeable": false
|
||||||
|
},
|
||||||
|
"sellerEstablished": 1,
|
||||||
|
"sellerName": "CAGE",
|
||||||
|
"startingBid": 0,
|
||||||
|
"tradeId": 1000004866,
|
||||||
|
"tradeState": "inactive",
|
||||||
|
"watched": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bidState": "none",
|
||||||
|
"buyNowPrice": 0,
|
||||||
|
"coinsProcessed": 0,
|
||||||
|
"currentBid": 0,
|
||||||
|
"expires": 0,
|
||||||
|
"itemData": {
|
||||||
|
"assetId": 177003,
|
||||||
|
"attributeList": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"value": 74
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"value": 75
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 2,
|
||||||
|
"value": 86
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 3,
|
||||||
|
"value": 89
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 4,
|
||||||
|
"value": 72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 5,
|
||||||
|
"value": 66
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cardassetid": 177003,
|
||||||
|
"cardsubtypeid": 0,
|
||||||
|
"contract": 7,
|
||||||
|
"definitionId": 177003,
|
||||||
|
"discardValue": 1500,
|
||||||
|
"fitness": 99,
|
||||||
|
"id": 100000122,
|
||||||
|
"itemState": "free",
|
||||||
|
"itemType": "player",
|
||||||
|
"leagueId": 53,
|
||||||
|
"nation": 10,
|
||||||
|
"owners": 1,
|
||||||
|
"playStyle": 250,
|
||||||
|
"preferredPosition": "CM",
|
||||||
|
"rareflag": 1,
|
||||||
|
"rating": 89,
|
||||||
|
"resourceId": 177003,
|
||||||
|
"teamid": 243,
|
||||||
|
"untradeable": false
|
||||||
|
},
|
||||||
|
"sellerEstablished": 1,
|
||||||
|
"sellerName": "CAGE",
|
||||||
|
"startingBid": 0,
|
||||||
|
"tradeId": 1000000122,
|
||||||
|
"tradeState": "inactive",
|
||||||
|
"watched": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bidState": "none",
|
||||||
|
"buyNowPrice": 0,
|
||||||
|
"coinsProcessed": 0,
|
||||||
|
"currentBid": 0,
|
||||||
|
"expires": 0,
|
||||||
|
"itemData": {
|
||||||
|
"assetId": 177003,
|
||||||
|
"attributeList": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"value": 74
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"value": 75
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 2,
|
||||||
|
"value": 86
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 3,
|
||||||
|
"value": 89
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 4,
|
||||||
|
"value": 72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 5,
|
||||||
|
"value": 66
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cardassetid": 177003,
|
||||||
|
"cardsubtypeid": 0,
|
||||||
|
"contract": 7,
|
||||||
|
"definitionId": 177003,
|
||||||
|
"discardValue": 1500,
|
||||||
|
"fitness": 99,
|
||||||
|
"id": 100000127,
|
||||||
|
"itemState": "free",
|
||||||
|
"itemType": "player",
|
||||||
|
"leagueId": 53,
|
||||||
|
"nation": 10,
|
||||||
|
"owners": 1,
|
||||||
|
"playStyle": 250,
|
||||||
|
"preferredPosition": "CM",
|
||||||
|
"rareflag": 1,
|
||||||
|
"rating": 89,
|
||||||
|
"resourceId": 177003,
|
||||||
|
"teamid": 243,
|
||||||
|
"untradeable": false
|
||||||
|
},
|
||||||
|
"sellerEstablished": 1,
|
||||||
|
"sellerName": "CAGE",
|
||||||
|
"startingBid": 0,
|
||||||
|
"tradeId": 1000000127,
|
||||||
|
"tradeState": "inactive",
|
||||||
|
"watched": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bidState": "none",
|
||||||
|
"buyNowPrice": 0,
|
||||||
|
"coinsProcessed": 0,
|
||||||
|
"currentBid": 0,
|
||||||
|
"expires": 0,
|
||||||
|
"itemData": {
|
||||||
|
"assetId": 158023,
|
||||||
|
"attributeList": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"value": 89
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"value": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 2,
|
||||||
|
"value": 86
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 3,
|
||||||
|
"value": 96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 4,
|
||||||
|
"value": 26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 5,
|
||||||
|
"value": 61
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cardassetid": 158023,
|
||||||
|
"cardsubtypeid": 0,
|
||||||
|
"contract": 7,
|
||||||
|
"definitionId": 158023,
|
||||||
|
"discardValue": 1500,
|
||||||
|
"fitness": 99,
|
||||||
|
"id": 100000059,
|
||||||
|
"itemState": "free",
|
||||||
|
"itemType": "player",
|
||||||
|
"leagueId": 53,
|
||||||
|
"nation": 52,
|
||||||
|
"owners": 1,
|
||||||
|
"playStyle": 250,
|
||||||
|
"preferredPosition": "RW",
|
||||||
|
"rareflag": 1,
|
||||||
|
"rating": 93,
|
||||||
|
"resourceId": 158023,
|
||||||
|
"teamid": 241,
|
||||||
|
"untradeable": false
|
||||||
|
},
|
||||||
|
"sellerEstablished": 1,
|
||||||
|
"sellerName": "CAGE",
|
||||||
|
"startingBid": 0,
|
||||||
|
"tradeId": 1000000059,
|
||||||
|
"tradeState": "inactive",
|
||||||
|
"watched": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bidState": "none",
|
||||||
|
"buyNowPrice": 0,
|
||||||
|
"coinsProcessed": 0,
|
||||||
|
"currentBid": 0,
|
||||||
|
"expires": 0,
|
||||||
|
"itemData": {
|
||||||
|
"assetId": 158023,
|
||||||
|
"attributeList": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"value": 89
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"value": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 2,
|
||||||
|
"value": 86
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 3,
|
||||||
|
"value": 96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 4,
|
||||||
|
"value": 26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 5,
|
||||||
|
"value": 61
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cardassetid": 158023,
|
||||||
|
"cardsubtypeid": 0,
|
||||||
|
"contract": 7,
|
||||||
|
"definitionId": 158023,
|
||||||
|
"discardValue": 1500,
|
||||||
|
"fitness": 99,
|
||||||
|
"id": 100000079,
|
||||||
|
"itemState": "free",
|
||||||
|
"itemType": "player",
|
||||||
|
"leagueId": 53,
|
||||||
|
"nation": 52,
|
||||||
|
"owners": 1,
|
||||||
|
"playStyle": 250,
|
||||||
|
"preferredPosition": "RW",
|
||||||
|
"rareflag": 1,
|
||||||
|
"rating": 93,
|
||||||
|
"resourceId": 158023,
|
||||||
|
"teamid": 241,
|
||||||
|
"untradeable": false
|
||||||
|
},
|
||||||
|
"sellerEstablished": 1,
|
||||||
|
"sellerName": "CAGE",
|
||||||
|
"startingBid": 0,
|
||||||
|
"tradeId": 1000000079,
|
||||||
|
"tradeState": "inactive",
|
||||||
|
"watched": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"credits": 29843976,
|
||||||
|
"duplicateItemIdList": [],
|
||||||
|
"total": 7
|
||||||
|
}
|
||||||
@@ -1052,6 +1052,21 @@ impl Fifa17IdentityResolver {
|
|||||||
.unwrap_or(None)
|
.unwrap_or(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The wire item id ALREADY mapped to a Core owned instance, or `None`.
|
||||||
|
///
|
||||||
|
/// NEVER allocates, unlike the resolver's forward path: enumerating the trade
|
||||||
|
/// pile must not mint identities for items the client has never seen, or a
|
||||||
|
/// read would quietly consume wire ids.
|
||||||
|
pub fn wire_for_owned_id(&self, core_id: &str) -> Option<i64> {
|
||||||
|
self.store
|
||||||
|
.external_for(
|
||||||
|
Fifa17WireItemIdPolicy::GAME,
|
||||||
|
Fifa17WireItemIdPolicy::OWNED_ITEM_KIND,
|
||||||
|
core_id,
|
||||||
|
)
|
||||||
|
.unwrap_or(None)
|
||||||
|
}
|
||||||
|
|
||||||
/// The FIFA `cardsubtypeid` for an owned item's definition (0 if unknown /
|
/// The FIFA `cardsubtypeid` for an owned item's definition (0 if unknown /
|
||||||
/// a player), from the catalog — used by club-stats family aggregation.
|
/// a player), from the catalog — used by club-stats family aggregation.
|
||||||
pub fn subtype_of(&self, item: &CoreOwnedItem) -> i64 {
|
pub fn subtype_of(&self, item: &CoreOwnedItem) -> i64 {
|
||||||
@@ -2347,39 +2362,44 @@ impl Server {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
EconomyRoute::MarketQuery => {
|
EconomyRoute::MarketQuery => {
|
||||||
// BOUNDED Q2 EXPERIMENT (off unless the env names one wire item id):
|
// Unlisted trade-pile members are exposed as `tradeState: "inactive"`
|
||||||
// expose a SINGLE unlisted trade-pile item as a non-active auction
|
// rows (LIVE-CONFIRMED; see docs/FIFA17_TRANSFER_MARKET_WIRE.md).
|
||||||
// record. Resolved synchronously here because the identity/Core
|
//
|
||||||
// resolvers are not `Send`, exactly like the market-list path.
|
// Three steps, in this order for a reason: the pile read is async, the
|
||||||
let unlisted = std::env::var("OPENFUT_FIFA17_UNLISTED_PROBE")
|
// identity/Core resolvers are NOT `Send` so they cannot cross an await,
|
||||||
.ok()
|
// and the response build is async again.
|
||||||
.and_then(|v| v.trim().parse::<i64>().ok())
|
|
||||||
.filter(|id| *id > 0)
|
|
||||||
.and_then(|id| {
|
|
||||||
let lookup = CoreItemLookup {
|
|
||||||
core: self.core.as_ref(),
|
|
||||||
};
|
|
||||||
crate::market::resolve_unlisted_candidate(
|
|
||||||
id,
|
|
||||||
self.resolver.as_ref(),
|
|
||||||
self.resolver.as_ref(),
|
|
||||||
&lookup,
|
|
||||||
self.entities.as_ref(),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
let (bridge, market, econ, piles) = (
|
let (bridge, market, econ, piles) = (
|
||||||
svc.bridge.clone(),
|
svc.bridge.clone(),
|
||||||
svc.market.clone(),
|
svc.market.clone(),
|
||||||
svc.econ.clone(),
|
svc.econ.clone(),
|
||||||
svc.piles.clone(),
|
svc.piles.clone(),
|
||||||
);
|
);
|
||||||
|
let trade_ids = {
|
||||||
|
let p = piles.clone();
|
||||||
|
bridge
|
||||||
|
.block_on(async move { p.list_by_pile("trade").await })
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let unlisted = if trade_ids.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
let lookup = CoreItemLookup {
|
||||||
|
core: self.core.as_ref(),
|
||||||
|
};
|
||||||
|
crate::market::resolve_unlisted_pile(
|
||||||
|
&trade_ids,
|
||||||
|
|core_id| self.resolver.wire_for_owned_id(core_id),
|
||||||
|
self.resolver.as_ref(),
|
||||||
|
&lookup,
|
||||||
|
self.entities.as_ref(),
|
||||||
|
)
|
||||||
|
};
|
||||||
bridge.block_on(async move {
|
bridge.block_on(async move {
|
||||||
crate::market::handle_market_query(
|
crate::market::handle_market_query(
|
||||||
"active",
|
"active",
|
||||||
econ.as_ref(),
|
econ.as_ref(),
|
||||||
market.as_ref(),
|
market.as_ref(),
|
||||||
piles.as_ref(),
|
&unlisted,
|
||||||
unlisted.as_ref(),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
|
|||||||
+142
-60
@@ -279,27 +279,48 @@ pub struct UnlistedCandidate {
|
|||||||
pub item_json: Option<String>,
|
pub item_json: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve ONE wire item id to an [`UnlistedCandidate`]. Pure identity plus a single
|
/// Resolve every Core owned instance sitting in the trade pile to an
|
||||||
/// Core inventory read, so it runs OFF the async runtime exactly like
|
/// [`UnlistedCandidate`]. Pure identity plus one Core inventory read each, so it runs
|
||||||
/// [`resolve_market_list`] (the trait-object resolvers are not `Send`).
|
/// OFF the async runtime exactly like [`resolve_market_list`] (the trait-object
|
||||||
pub fn resolve_unlisted_candidate<E: ReverseEntityResolver>(
|
/// resolvers are not `Send`).
|
||||||
item_id: i64,
|
///
|
||||||
reverse: &dyn SquadWireResolver,
|
/// `wire_for_core` MUST be a non-allocating lookup: enumerating a pile is a READ, and
|
||||||
|
/// minting a wire id for an item the client has never seen would silently consume
|
||||||
|
/// identities. An item with no existing mapping, no Core record, or no resolvable
|
||||||
|
/// FIFA identity is skipped rather than faked.
|
||||||
|
pub fn resolve_unlisted_pile<E, F>(
|
||||||
|
core_ids: &[String],
|
||||||
|
wire_for_core: F,
|
||||||
resolver: &dyn ItemIdentityResolver,
|
resolver: &dyn ItemIdentityResolver,
|
||||||
items: &dyn OwnedItemLookup,
|
items: &dyn OwnedItemLookup,
|
||||||
ent: &E,
|
ent: &E,
|
||||||
) -> Option<UnlistedCandidate> {
|
) -> Vec<UnlistedCandidate>
|
||||||
let core_id = reverse.owned_id_for_wire(item_id)?;
|
where
|
||||||
let owned = items.owned_item(&core_id)?;
|
E: ReverseEntityResolver,
|
||||||
let item_json = resolver
|
F: Fn(&str) -> Option<i64>,
|
||||||
.resolve(&owned)
|
{
|
||||||
.map(|id| shape_item(&owned, id, ent))
|
let mut out = Vec::with_capacity(core_ids.len());
|
||||||
.and_then(|card| serde_json::to_string(&card).ok());
|
for core_id in core_ids {
|
||||||
Some(UnlistedCandidate {
|
let Some(item_id) = wire_for_core(core_id) else {
|
||||||
item_id,
|
continue;
|
||||||
core_id,
|
};
|
||||||
item_json,
|
let Some(owned) = items.owned_item(core_id) else {
|
||||||
})
|
continue;
|
||||||
|
};
|
||||||
|
let Some(item_json) = resolver
|
||||||
|
.resolve(&owned)
|
||||||
|
.map(|id| shape_item(&owned, id, ent))
|
||||||
|
.and_then(|card| serde_json::to_string(&card).ok())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
out.push(UnlistedCandidate {
|
||||||
|
item_id,
|
||||||
|
core_id: core_id.clone(),
|
||||||
|
item_json: Some(item_json),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The auction record for an item sitting in the trade pile with NO auction.
|
/// The auction record for an item sitting in the trade pile with NO auction.
|
||||||
@@ -463,8 +484,7 @@ pub async fn handle_market_query(
|
|||||||
state: &str,
|
state: &str,
|
||||||
econ: &dyn CoreEconomy,
|
econ: &dyn CoreEconomy,
|
||||||
store: &MarketStore,
|
store: &MarketStore,
|
||||||
piles: &PileStore,
|
unlisted: &[UnlistedCandidate],
|
||||||
unlisted: Option<&UnlistedCandidate>,
|
|
||||||
) -> WireResponse {
|
) -> WireResponse {
|
||||||
let listings = match store.query_listings(state).await {
|
let listings = match store.query_listings(state).await {
|
||||||
Ok(l) => l,
|
Ok(l) => l,
|
||||||
@@ -475,27 +495,27 @@ pub async fn handle_market_query(
|
|||||||
.map(|l| auction_record_as(l, "listFS"))
|
.map(|l| auction_record_as(l, "listFS"))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// BOUNDED Q2 EXPERIMENT: expose ONE unlisted trade-pile item as a non-active
|
// Unlisted trade-pile members, as LIVE-CONFIRMED `tradeState: "inactive"` rows.
|
||||||
// record. Every precondition is re-checked here so the row can never contradict
|
// The caller enumerates them FROM the pile, so membership is established; what
|
||||||
// the auction state: the item must actually be in the `trade` pile, and it must
|
// must still be excluded is any item whose listing state forbids advertising it as
|
||||||
// NOT already own a listing (otherwise it would duplicate a real auction).
|
// an owned unlisted card. A pile row OUTLIVES its auction, so after a sale the
|
||||||
if let Some(c) = unlisted {
|
// seller's `trade` row is stale — filtering on the active set alone would
|
||||||
let in_trade_pile = matches!(piles.get(&c.core_id).await, Ok(Some(p)) if p == "trade");
|
// re-advertise a SOLD card as an unlisted item (caught by the differential).
|
||||||
let already_listed = listings
|
let blocked = store.blocking_core_items().await.unwrap_or_default();
|
||||||
.iter()
|
let mut inactive = 0usize;
|
||||||
.any(|l| l.core_item_id.as_deref() == Some(c.core_id.as_str()));
|
for c in unlisted {
|
||||||
if in_trade_pile && !already_listed {
|
if blocked.contains(&c.core_id) {
|
||||||
auctions.push(unlisted_record(c));
|
continue;
|
||||||
eprintln!(
|
|
||||||
"utas-host owner=RUST route=market-query unlisted_candidate item_id={} state=inactive",
|
|
||||||
c.item_id
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
eprintln!(
|
|
||||||
"utas-host owner=RUST route=market-query unlisted_candidate item_id={} skipped in_pile={in_trade_pile} listed={already_listed}",
|
|
||||||
c.item_id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
auctions.push(unlisted_record(c));
|
||||||
|
inactive += 1;
|
||||||
|
}
|
||||||
|
if !unlisted.is_empty() {
|
||||||
|
eprintln!(
|
||||||
|
"utas-host owner=RUST route=market-query active={} inactive={inactive} pile_candidates={}",
|
||||||
|
listings.len(),
|
||||||
|
unlisted.len()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTradePile shares one deserializer (0x18013e7f0) with ISSearch and
|
// GetTradePile shares one deserializer (0x18013e7f0) with ISSearch and
|
||||||
@@ -974,14 +994,6 @@ mod tests {
|
|||||||
(store, db)
|
(store, db)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An empty pile store on its own temp file, for tradePile tests that do not
|
|
||||||
/// exercise the unlisted candidate.
|
|
||||||
async fn empty_piles() -> (PileStore, TempDb) {
|
|
||||||
let db = TempDb::new("piles");
|
|
||||||
let piles = PileStore::open(db.path()).await.unwrap();
|
|
||||||
(piles, db)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn unlisted_candidate_is_a_non_active_pile_row() {
|
async fn unlisted_candidate_is_a_non_active_pile_row() {
|
||||||
// Q2: an item in the trade pile with NO auction. The route table has exactly
|
// Q2: an item in the trade pile with NO auction. The route table has exactly
|
||||||
@@ -990,7 +1002,6 @@ mod tests {
|
|||||||
// lifecycle — whose PE string table is `active/inactive/expired/closed`. So
|
// lifecycle — whose PE string table is `active/inactive/expired/closed`. So
|
||||||
// the row must be `inactive`, with no timer and no prices.
|
// the row must be `inactive`, with no timer and no prices.
|
||||||
let (store, _d) = store_at("unlisted").await;
|
let (store, _d) = store_at("unlisted").await;
|
||||||
let (piles, _pd) = empty_piles().await;
|
|
||||||
let econ = CountingEconomy::with_balance(10_000);
|
let econ = CountingEconomy::with_balance(10_000);
|
||||||
let c = UnlistedCandidate {
|
let c = UnlistedCandidate {
|
||||||
item_id: 100_000_059,
|
item_id: 100_000_059,
|
||||||
@@ -998,16 +1009,15 @@ mod tests {
|
|||||||
item_json: Some(r#"{"rating":93,"preferredPosition":"RW"}"#.into()),
|
item_json: Some(r#"{"rating":93,"preferredPosition":"RW"}"#.into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Not in the trade pile yet -> deliberately NOT exposed.
|
// No candidates supplied -> nothing extra is advertised.
|
||||||
let body = parse(&handle_market_query("active", &econ, &store, &piles, Some(&c)).await);
|
let body = parse(&handle_market_query("active", &econ, &store, &[]).await);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
body["auctionInfo"].as_array().unwrap().len(),
|
body["auctionInfo"].as_array().unwrap().len(),
|
||||||
0,
|
0,
|
||||||
"an item that is not in the trade pile must never be advertised as one"
|
"an empty candidate set adds nothing"
|
||||||
);
|
);
|
||||||
|
|
||||||
piles.set("core-unlisted", "trade").await.unwrap();
|
let body = parse(&handle_market_query("active", &econ, &store, std::slice::from_ref(&c)).await);
|
||||||
let body = parse(&handle_market_query("active", &econ, &store, &piles, Some(&c)).await);
|
|
||||||
let recs = body["auctionInfo"].as_array().unwrap();
|
let recs = body["auctionInfo"].as_array().unwrap();
|
||||||
assert_eq!(recs.len(), 1, "now in the pile, so it is exposed");
|
assert_eq!(recs.len(), 1, "now in the pile, so it is exposed");
|
||||||
let r = &recs[0];
|
let r = &recs[0];
|
||||||
@@ -1034,7 +1044,6 @@ mod tests {
|
|||||||
// If the item already owns a listing, advertising it as `inactive` too would
|
// If the item already owns a listing, advertising it as `inactive` too would
|
||||||
// put two records with the same tradeId in one body.
|
// put two records with the same tradeId in one body.
|
||||||
let (store, _d) = store_at("unlistedduped").await;
|
let (store, _d) = store_at("unlistedduped").await;
|
||||||
let (piles, _pd) = empty_piles().await;
|
|
||||||
let econ = CountingEconomy::with_balance(10_000);
|
let econ = CountingEconomy::with_balance(10_000);
|
||||||
store
|
store
|
||||||
.create_listing(
|
.create_listing(
|
||||||
@@ -1051,18 +1060,91 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
piles.set("core-unlisted", "trade").await.unwrap();
|
|
||||||
let c = UnlistedCandidate {
|
let c = UnlistedCandidate {
|
||||||
item_id: 100_000_059,
|
item_id: 100_000_059,
|
||||||
core_id: "core-unlisted".into(),
|
core_id: "core-unlisted".into(),
|
||||||
item_json: None,
|
item_json: None,
|
||||||
};
|
};
|
||||||
let body = parse(&handle_market_query("active", &econ, &store, &piles, Some(&c)).await);
|
let body = parse(&handle_market_query("active", &econ, &store, std::slice::from_ref(&c)).await);
|
||||||
let recs = body["auctionInfo"].as_array().unwrap();
|
let recs = body["auctionInfo"].as_array().unwrap();
|
||||||
assert_eq!(recs.len(), 1, "the real auction only");
|
assert_eq!(recs.len(), 1, "the real auction only");
|
||||||
assert_eq!(recs[0]["tradeState"], "active");
|
assert_eq!(recs[0]["tradeState"], "active");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_sold_or_in_flight_card_is_never_advertised_as_an_unlisted_member() {
|
||||||
|
// A pile row OUTLIVES its auction: after a sale the seller's `trade` row is
|
||||||
|
// stale. Filtering only on ACTIVE listings therefore re-advertised a SOLD card
|
||||||
|
// as an owned unlisted item — caught by the differential, not by unit tests,
|
||||||
|
// because it needs the full list -> sell -> poll sequence.
|
||||||
|
let (store, _d) = store_at("soldpile").await;
|
||||||
|
let econ = CountingEconomy::with_balance(10_000);
|
||||||
|
let mk = |id: &str, core: &str| {
|
||||||
|
let store = store.clone();
|
||||||
|
let (id, core) = (id.to_string(), core.to_string());
|
||||||
|
async move {
|
||||||
|
store
|
||||||
|
.create_listing(
|
||||||
|
&id,
|
||||||
|
"169193",
|
||||||
|
Some(&core),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
150,
|
||||||
|
2500,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mk("900000070", "core-sold").await;
|
||||||
|
mk("900000071", "core-inflight").await;
|
||||||
|
mk("900000072", "core-cancelled").await;
|
||||||
|
store.reserve_listing("900000070").await.unwrap();
|
||||||
|
store.complete_sale("900000070").await.unwrap();
|
||||||
|
store.reserve_listing("900000071").await.unwrap();
|
||||||
|
store.cancel_listing("900000072", None).await.unwrap();
|
||||||
|
|
||||||
|
let cands: Vec<UnlistedCandidate> = ["core-sold", "core-inflight", "core-cancelled"]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, core)| UnlistedCandidate {
|
||||||
|
item_id: 100_000_200 + i as i64,
|
||||||
|
core_id: (*core).to_string(),
|
||||||
|
item_json: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let body = parse(&handle_market_query("active", &econ, &store, &cands).await);
|
||||||
|
let recs = body["auctionInfo"].as_array().unwrap();
|
||||||
|
let states: Vec<&str> = recs
|
||||||
|
.iter()
|
||||||
|
.map(|r| r["tradeState"].as_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
states,
|
||||||
|
vec!["inactive"],
|
||||||
|
"only the CANCELLED card returns to the pile as unlisted; sold and \
|
||||||
|
in-flight cards must not be advertised as owned"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
recs[0]["itemData"]["id"], 100_000_202i64,
|
||||||
|
"and it is specifically the cancelled one"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The store-level rule, asserted directly.
|
||||||
|
let blocked = store.blocking_core_items().await.unwrap();
|
||||||
|
assert!(blocked.contains("core-sold"));
|
||||||
|
assert!(blocked.contains("core-inflight"));
|
||||||
|
assert!(
|
||||||
|
!blocked.contains("core-cancelled"),
|
||||||
|
"a cancelled listing means the card is back in the pile"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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, Some(169193), 400, buy_now, None, None, None)
|
.create_listing(id, "169193", None, None, Some(169193), 400, buy_now, None, None, None)
|
||||||
@@ -1114,7 +1196,7 @@ mod tests {
|
|||||||
assert_eq!(snap["preferredPosition"], "ST");
|
assert_eq!(snap["preferredPosition"], "ST");
|
||||||
assert_eq!(snap["attributeList"].as_array().unwrap().len(), 6);
|
assert_eq!(snap["attributeList"].as_array().unwrap().len(), 6);
|
||||||
// tradePile embeds that full card and stamps the seller-pile state.
|
// tradePile embeds that full card and stamps the seller-pile state.
|
||||||
let pile = handle_market_query("active", &econ, &store, &empty_piles().await.0, None).await;
|
let pile = handle_market_query("active", &econ, &store, &[]).await;
|
||||||
let rec = parse(&pile)["auctionInfo"][0].clone();
|
let rec = parse(&pile)["auctionInfo"][0].clone();
|
||||||
assert_eq!(rec["itemData"]["itemState"], "listFS");
|
assert_eq!(rec["itemData"]["itemState"], "listFS");
|
||||||
assert_eq!(rec["itemData"]["rating"], 84);
|
assert_eq!(rec["itemData"]["rating"], 84);
|
||||||
@@ -1211,7 +1293,7 @@ mod tests {
|
|||||||
let (store, _d) = store_at("query").await;
|
let (store, _d) = store_at("query").await;
|
||||||
seed_listing(&store, "900000005", 2500).await;
|
seed_listing(&store, "900000005", 2500).await;
|
||||||
let econ = CountingEconomy::with_balance(50);
|
let econ = CountingEconomy::with_balance(50);
|
||||||
let resp = handle_market_query("active", &econ, &store, &empty_piles().await.0, None).await;
|
let resp = handle_market_query("active", &econ, &store, &[]).await;
|
||||||
let b = parse(&resp);
|
let b = parse(&resp);
|
||||||
assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1);
|
assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1);
|
||||||
assert_eq!(b["auctionInfo"][0]["tradeId"], 900000005i64);
|
assert_eq!(b["auctionInfo"][0]["tradeId"], 900000005i64);
|
||||||
@@ -1261,7 +1343,7 @@ mod tests {
|
|||||||
let econ = CountingEconomy::with_balance(10_000);
|
let econ = CountingEconomy::with_balance(10_000);
|
||||||
seed_listing(&store, "900000030", 2500).await;
|
seed_listing(&store, "900000030", 2500).await;
|
||||||
|
|
||||||
let body = parse(&handle_market_query("active", &econ, &store, &empty_piles().await.0, None).await);
|
let body = parse(&handle_market_query("active", &econ, &store, &[]).await);
|
||||||
let rec = body["auctionInfo"][0].clone();
|
let rec = body["auctionInfo"][0].clone();
|
||||||
let mut got: Vec<&str> = rec.as_object().unwrap().keys().map(String::as_str).collect();
|
let mut got: Vec<&str> = rec.as_object().unwrap().keys().map(String::as_str).collect();
|
||||||
got.sort_unstable();
|
got.sort_unstable();
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
//! `CHECK` constraint even though it is a transient intermediate — omitting it
|
//! `CHECK` constraint even though it is a transient intermediate — omitting it
|
||||||
//! would make [`MarketStore::reserve_listing`] fail the constraint.
|
//! would make [`MarketStore::reserve_listing`] fail the constraint.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -619,6 +620,30 @@ impl MarketStore {
|
|||||||
.map_err(db)?
|
.map_err(db)?
|
||||||
.rows_affected())
|
.rows_affected())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Core owned-instance ids whose listing state FORBIDS advertising them as an
|
||||||
|
/// unlisted pile member: `active` (a real auction is shown instead), `reserved`
|
||||||
|
/// (a sale is in flight) and `sold` (the card is gone).
|
||||||
|
///
|
||||||
|
/// `cancelled` is deliberately absent: a cancelled listing means the card came
|
||||||
|
/// back to the pile and IS an unlisted member again.
|
||||||
|
///
|
||||||
|
/// Needed because a pile row outlives its auction — after a sale the seller's
|
||||||
|
/// `trade` row is stale, and filtering on the active set alone would re-advertise
|
||||||
|
/// a sold card as an owned unlisted item.
|
||||||
|
pub async fn blocking_core_items(&self) -> Result<HashSet<String>, MarketError> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT DISTINCT core_item_id FROM listings \
|
||||||
|
WHERE core_item_id IS NOT NULL AND state IN ('active','reserved','sold')",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db)?;
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.filter_map(|r| r.get::<Option<String>, _>("core_item_id"))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
Reference in New Issue
Block a user