feat(market): isolated two-identity SOLD-row A/B harness (staging only, not promoted)
Static RE exhausted CardsDLL on the one open question: for a closed row
IS_GLOW = (bidState != none) and INBOX = (bidState in {highest, buyNow}), so
closed/highest and closed/buyNow are BIT-IDENTICAL natively. But bidState is
published to the movie verbatim as YOURBID, so the FUT ActionScript CAN separate
them. This builds the controlled experiment that asks the client which one it
treats as the seller's sale.
PRODUCTION SAFETY IS THE FIRST CONCERN
New module openfut-utas-host/src/sold_experiment.rs. Every knob is OFF unless its
env var is set, an unrecognised value is OFF rather than a default token (silently
picking one would fabricate the answer being measured), and the host logs a startup
banner naming the active variant so a staging capture can never be mistaken for a
production one. With no env set, /tradePile and /trade/status emit only real active
auctions (the Fix A invariant) and counts still report sold: 0. The entire existing
test suite now passes SoldExperiment::OFF explicitly, making it a regression guard.
OPENFUT_FIFA17_SOLD_EXPERIMENT = highest | buyNow (else OFF)
OPENFUT_FIFA17_SOLD_COINS_PROCESSED = 1 (else 0)
OPENFUT_FIFA17_SOLD_COUNT_MODE = active_plus_sold (else active)
WHAT THE EXPERIMENT PROJECTS
Uncleared sold listings appear in /tradePile and /trade/status as tradeState
"closed" with the token under test and currentBid = the sale price; counts report
the real sold tally. There is ONE record builder, so the A/B changes only what is
passed into it, and a test asserts that EXACTLY ONE field differs between the two
variants -- without that control the client's reaction is not attributable to the
token and the whole experiment is void. coinsProcessed (Flash COINS_AWARDED) varies
independently so the third pass cannot be confounded with the first.
CLEAR-SOLD, PE-PROVEN
New EconomyRoute::MarketClearSold for DELETE .../trade/sold, classified BEFORE the
generic trade cancel arm -- a `sold` tail carries no id, so the cancel handler would
have parsed nothing and acked while clearing nothing. Builder 0x1801647c0 emits
"/sold" when the tradeId field is zero and "/%lld" otherwise; the client calls it
RemoveAllSoldFromTradePile. New market-store column cleared_at records the seller's
acknowledgement SEPARATELY from the sale, so clearing can never be mistaken for
re-settling: it is presentation only, moves no coins and no ownership, and is
idempotent for client retries.
FOUND AND FIXED A LATENT STORE BUG
Adding a column via the additive ALTER path immediately after CREATE TABLE in the
same open() desynced sqlx's per-connection schema cache: a fresh store then read a
12-column row while metadata said 13, panicking a pool worker with an index
out-of-bounds and silently returning zero listings. Declaring cleared_at in
CREATE_LISTINGS fixes it; the ALTER now only serves pre-existing stores. This would
have bitten the next column too.
STAGING, WITHOUT TOUCHING PRODUCTION
The client learns the UTAS base from BLAZE (blaze_responder_v3b.py:646 hardcodes
:8099), and it dials that port directly, so redirecting UTAS means changing Blaze or
port 8099 -- both production. 10.10.0.121 is unreachable. The compliant path is a
parallel stack on spare ports plus a one-line change to the CLIENT's own config:
* scripts/sold-staging-up.py / sold-staging-down.py -- staging Core 18081,
utas-host 8299, Blaze 42327/42330/42331 advertising :8299, two seeded identities,
own DBs under /home/alex/openfut-sold-staging/. Patches a COPY of the Blaze
responder and asserts every substitution applied, so a silent no-op cannot leave
it pointing at production. Kills only recorded pids whose cmdline contains the
staging dir (openfut-utas-host matches BOTH, so pkill-by-pattern is banned).
* docs/SOLD_STAGING_RUNBOOK.md -- the exact client change and its revert.
* src/bin/staging_sell.rs -- the synthetic Buyer B, running the REAL settlement
(CoreEconomy::settle_sale) then mark_sold. Settle-first ordering: a failure
leaves the listing live with nothing moved. Refuses any path containing
openfut-promotion or the production ports.
* scripts/sold-wire-check.py -- proves the whole flow headless before any operator
time is spent.
WIRE CHECK: 35/35 PASS on the canonical 150-coin sale. Seller 1,000 -> 1,143 (fee 7,
proceeds 143), buyer 20,000 -> 19,850, ownership transferred, exactly ONE
authoritative instance, economy shrank by exactly the fee. Sold row: closed,
currentBid 150, expires 0, twelve atoms, counts sold 1 / selling 0, /trade/status
agreeing. Variant B differs only in bidState and coinsProcessed. Clear: 200 {}, row
gone, counts.sold 0, no coins moved, buyer keeps the item, second clear a safe no-op.
Gates: 104 host lib tests (+9), all 7 host targets green, clippy clean, zero fmt
diffs in the new code. Settlement candidate unchanged. NOT PROMOTED.
Production untouched: prod-host pid 3631953 uptime 2h44m restarts=0, coins and
/tradePile unchanged, nothing under /home/alex/openfut-promotion/state/ opened.
The A/B itself is NOT yet run: it needs a real FIFA client, which is operator work.
This commit is contained in:
+401
-22
@@ -35,6 +35,7 @@ use crate::economy_store::OwnedItemLookup;
|
||||
|
||||
use crate::market_store::{now_secs, Listing, MarketError, MarketStore};
|
||||
use crate::pile_store::PileStore;
|
||||
use crate::sold_experiment::{CountMode, SoldExperiment};
|
||||
use crate::{CoreEconomy, CoreError, WireResponse};
|
||||
|
||||
/// FIFA trade-id numbering base (mirrors the oracle's `_TRADE_ID_BASE`).
|
||||
@@ -88,6 +89,25 @@ fn trade_id_from_path(path: &str) -> Option<String> {
|
||||
/// is handed an unrecognised `CARD_OFFERSTATE`. Where the binary contradicts the
|
||||
/// oracle, the binary wins.
|
||||
fn auction_record_as(l: &Listing, item_state: &str) -> Value {
|
||||
auction_record_tuned(l, item_state, None, 0)
|
||||
}
|
||||
|
||||
/// [`auction_record_as`] with the two fields the staging sold experiment varies.
|
||||
///
|
||||
/// `sold_bid_state` overrides `bidState` for a terminal (non-active) listing, and
|
||||
/// `coins_processed` sets the atom the client publishes to Flash as
|
||||
/// `COINS_AWARDED`. Both default to today's production values via
|
||||
/// [`auction_record_as`], so nothing changes unless the experiment is on.
|
||||
///
|
||||
/// Everything else is byte-identical between variants BY CONSTRUCTION: there is
|
||||
/// one record builder, and the A/B changes only what is passed in here. That is
|
||||
/// what makes the client's reaction attributable to the token.
|
||||
fn auction_record_tuned(
|
||||
l: &Listing,
|
||||
item_state: &str,
|
||||
sold_bid_state: Option<&str>,
|
||||
coins_processed: i64,
|
||||
) -> Value {
|
||||
let trade_id: i64 = l.listing_id.parse().unwrap_or(0);
|
||||
// resourceId is the FIFA wire identity the client listed (never the Core
|
||||
// card id). 0 means "no art", a valid int — never a fabricated FIFA asset.
|
||||
@@ -113,7 +133,15 @@ fn auction_record_as(l: &Listing, item_state: &str) -> Value {
|
||||
let (trade_state, bid_state, current_bid) = match l.state.as_str() {
|
||||
"active" if expires == 0 => ("expired", "none", 0),
|
||||
"active" => ("active", "none", 0),
|
||||
_ => ("closed", "highest", l.buy_now_price),
|
||||
// Terminal. `closed` is the only FIFA 17 token for "this auction is over
|
||||
// and something happened"; there is no `sold`. The experiment varies which
|
||||
// bidState rides along, because that is the one thing the movie can see
|
||||
// (published verbatim as YOURBID) and the native flags cannot distinguish.
|
||||
_ => (
|
||||
"closed",
|
||||
sold_bid_state.unwrap_or("highest"),
|
||||
l.buy_now_price,
|
||||
),
|
||||
};
|
||||
let item_data = l
|
||||
.item_json
|
||||
@@ -164,7 +192,10 @@ fn auction_record_as(l: &Listing, item_state: &str) -> Value {
|
||||
.unwrap_or_else(|| non_economy::PERSONA_DISPLAY_NAME.to_string()),
|
||||
"sellerEstablished": 1,
|
||||
"watched": false,
|
||||
"coinsProcessed": 0,
|
||||
// Published to Flash as COINS_AWARDED (record +0xbf, atom 0x2f4, u8).
|
||||
// Production emits 0; the experiment's third pass varies it to learn
|
||||
// whether the client treats it as informational or as a gate.
|
||||
"coinsProcessed": coins_processed,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -404,15 +435,42 @@ pub async fn handle_market_query(
|
||||
state: &str,
|
||||
econ: &dyn CoreEconomy,
|
||||
store: &MarketStore,
|
||||
exp: SoldExperiment,
|
||||
) -> WireResponse {
|
||||
let listings = match store.query_listings(state).await {
|
||||
Ok(l) => l,
|
||||
Err(_) => return json_body(503, &json!({ "error": "market_store" })),
|
||||
};
|
||||
let auctions: Vec<Value> = listings
|
||||
let mut auctions: Vec<Value> = listings
|
||||
.iter()
|
||||
.map(|l| auction_record_as(l, "forSale"))
|
||||
.collect();
|
||||
// STAGING ONLY. FIFA 17's bulk `DELETE …/trade/sold` verb only makes sense if
|
||||
// sold rows persist in the seller's pile until acknowledged, so the experiment
|
||||
// projects uncleared sold listings alongside the active ones. Off in
|
||||
// production, where this stays exactly the Fix A invariant: active auctions
|
||||
// only.
|
||||
if exp.enabled() {
|
||||
if let Ok(sold) = store.uncleared_sold().await {
|
||||
for l in &sold {
|
||||
auctions.push(auction_record_tuned(
|
||||
l,
|
||||
"forSale",
|
||||
exp.bid_state,
|
||||
exp.coins_processed,
|
||||
));
|
||||
}
|
||||
if !sold.is_empty() {
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=market-query SOLD-EXPERIMENT \
|
||||
sold_rows={} bidState={:?} coinsProcessed={}",
|
||||
sold.len(),
|
||||
exp.bid_state,
|
||||
exp.coins_processed
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// GetTradePile shares one deserializer (0x18013e7f0) with ISSearch and
|
||||
// ISWatchList, over exactly four members: `auctionInfo` (array), `credits`
|
||||
// (int), `duplicateItemIdList` (array of objects) and `total` (int). We were
|
||||
@@ -434,21 +492,59 @@ pub async fn handle_market_query(
|
||||
/// freeze risk. They are the only inputs to IS_MAX_AUCTIONS, so
|
||||
/// `maxAuctionsAllowed = 100` with `selling < 100` keeps the listing cap open.
|
||||
/// A store read failure degrades to zeros (cosmetic tally, never fail-closed).
|
||||
pub async fn handle_market_counts(store: &MarketStore) -> WireResponse {
|
||||
let n = store
|
||||
///
|
||||
/// `sold` is NOT cosmetic: RE proved atom `sold` (0x2c9) reaches the hub tile as
|
||||
/// Flash `TEXT3` under the localised caption `FUT_TF_SOLD`, so the seller really
|
||||
/// does see a SOLD bucket. Production still reports 0 because we have never had a
|
||||
/// sold row; the experiment reports the real count so the client can be observed.
|
||||
pub async fn handle_market_counts(store: &MarketStore, exp: SoldExperiment) -> WireResponse {
|
||||
let selling = store
|
||||
.query_listings("active")
|
||||
.await
|
||||
.map(|l| l.len() as i64)
|
||||
.unwrap_or(0);
|
||||
let sold = if exp.enabled() {
|
||||
store
|
||||
.uncleared_sold()
|
||||
.await
|
||||
.map(|l| l.len() as i64)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// FIFA 17's exact meaning for `count` is unknown — live auctions, or whole
|
||||
// Transfer List membership. It is a controlled variable, never a guess.
|
||||
let count = match exp.count_mode {
|
||||
CountMode::Active => selling,
|
||||
CountMode::ActivePlusSold => selling + sold,
|
||||
};
|
||||
ok_json(&json!({
|
||||
"count": n,
|
||||
"count": count,
|
||||
"maxAuctionsAllowed": 100,
|
||||
"offered": 0,
|
||||
"selling": n,
|
||||
"sold": 0,
|
||||
"selling": selling,
|
||||
"sold": sold,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `DELETE /ut/delete/game/<sku>/trade/sold` — the bulk clear-sold verb.
|
||||
///
|
||||
/// PE-proven: the request builder `0x1801647c0` emits the literal `/sold` when the
|
||||
/// tradeId field is zero and `/%lld` otherwise, and the client's request-name table
|
||||
/// calls it `RemoveAllSoldFromTradePile`. The response body parses nothing, so `{}`
|
||||
/// is the whole contract.
|
||||
///
|
||||
/// PRESENTATION ONLY. Settlement already happened when the sale completed; this
|
||||
/// records the seller's acknowledgement. It must never move coins or ownership,
|
||||
/// or a client retry would pay twice.
|
||||
pub async fn handle_market_clear_sold(store: &MarketStore) -> WireResponse {
|
||||
match store.clear_sold().await {
|
||||
Ok(n) => eprintln!("utas-host owner=RUST route=market-clear-sold cleared={n}"),
|
||||
Err(e) => eprintln!("utas-host WARN market clear_sold failed: {e}"),
|
||||
}
|
||||
ok_json(&json!({}))
|
||||
}
|
||||
|
||||
/// `DELETE /ut/delete/game/<sku>/trade/<id>` — remove a listing from the sale
|
||||
/// pile. Cancels the `active` listing once; the oracle always acks `{}`, so a
|
||||
/// missing/already-closed listing is not surfaced as an error to the client
|
||||
@@ -482,13 +578,23 @@ pub async fn handle_market_status(
|
||||
query: Option<&str>,
|
||||
econ: &dyn CoreEconomy,
|
||||
store: &MarketStore,
|
||||
exp: SoldExperiment,
|
||||
) -> WireResponse {
|
||||
let ids = trade_ids_from_query(query);
|
||||
let listings = if ids.is_empty() {
|
||||
match store.query_listings("active").await {
|
||||
let mut all = match store.query_listings("active").await {
|
||||
Ok(l) => l,
|
||||
Err(_) => return json_body(503, &json!({ "error": "market_store" })),
|
||||
};
|
||||
// Unfiltered poll: the experiment's sold rows are part of what the screen
|
||||
// is showing, so they must answer here too or the row would render from
|
||||
// /tradePile and then contradict its own status poll.
|
||||
if exp.enabled() {
|
||||
if let Ok(sold) = store.uncleared_sold().await {
|
||||
all.extend(sold);
|
||||
}
|
||||
}
|
||||
all
|
||||
} else {
|
||||
let mut found = Vec::with_capacity(ids.len());
|
||||
for id in &ids {
|
||||
@@ -500,7 +606,7 @@ pub async fn handle_market_status(
|
||||
};
|
||||
let auctions: Vec<Value> = listings
|
||||
.iter()
|
||||
.map(|l| auction_record_as(l, "forSale"))
|
||||
.map(|l| auction_record_tuned(l, "forSale", exp.bid_state, exp.coins_processed))
|
||||
.collect();
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=market-status requested={} returned={} query={}",
|
||||
@@ -941,7 +1047,7 @@ mod tests {
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
|
||||
// Nothing listed: an empty pile, whatever the item store holds.
|
||||
let body = parse(&handle_market_query("active", &econ, &store).await);
|
||||
let body = parse(&handle_market_query("active", &econ, &store, SoldExperiment::OFF).await);
|
||||
assert_eq!(body["auctionInfo"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(body["total"], 0);
|
||||
|
||||
@@ -961,7 +1067,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = parse(&handle_market_query("active", &econ, &store).await);
|
||||
let body = parse(&handle_market_query("active", &econ, &store, SoldExperiment::OFF).await);
|
||||
let recs = body["auctionInfo"].as_array().unwrap();
|
||||
assert_eq!(recs.len(), 1, "the real auction, and nothing synthetic");
|
||||
assert_eq!(recs[0]["tradeState"], "active");
|
||||
@@ -1037,7 +1143,7 @@ mod tests {
|
||||
// nowhere in CardsDLL or in 4.26 GiB of live process memory and decoded to
|
||||
// -1, so the client was handed an unrecognised CARD_OFFERSTATE. `forSale`
|
||||
// (5) is the value in FIFA 17's own itemState table.
|
||||
let pile = handle_market_query("active", &econ, &store).await;
|
||||
let pile = handle_market_query("active", &econ, &store, SoldExperiment::OFF).await;
|
||||
let rec = parse(&pile)["auctionInfo"][0].clone();
|
||||
assert_eq!(rec["itemData"]["itemState"], "forSale");
|
||||
assert_eq!(rec["itemData"]["rating"], 84);
|
||||
@@ -1137,7 +1243,7 @@ mod tests {
|
||||
let (store, _d) = store_at("query").await;
|
||||
seed_listing(&store, "900000005", 2500).await;
|
||||
let econ = CountingEconomy::with_balance(50);
|
||||
let resp = handle_market_query("active", &econ, &store).await;
|
||||
let resp = handle_market_query("active", &econ, &store, SoldExperiment::OFF).await;
|
||||
let b = parse(&resp);
|
||||
assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(b["auctionInfo"][0]["tradeId"], 900000005i64);
|
||||
@@ -1151,12 +1257,12 @@ mod tests {
|
||||
// every count at its constructor default, so the Transfer List screen
|
||||
// shows no active sale even while the hub tile reports one (live bug).
|
||||
let (store, _d) = store_at("counts").await;
|
||||
let b = parse(&handle_market_counts(&store).await);
|
||||
let b = parse(&handle_market_counts(&store, SoldExperiment::OFF).await);
|
||||
assert_eq!(b["count"], 0);
|
||||
assert_eq!(b["selling"], 0);
|
||||
|
||||
seed_listing(&store, "900000007", 2500).await;
|
||||
let resp = handle_market_counts(&store).await;
|
||||
let resp = handle_market_counts(&store, SoldExperiment::OFF).await;
|
||||
assert_eq!(resp.status, 200);
|
||||
let b = parse(&resp);
|
||||
assert_eq!(b["count"], 1, "tally counts the active listing");
|
||||
@@ -1187,7 +1293,7 @@ mod tests {
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
seed_listing(&store, "900000030", 2500).await;
|
||||
|
||||
let body = parse(&handle_market_query("active", &econ, &store).await);
|
||||
let body = parse(&handle_market_query("active", &econ, &store, SoldExperiment::OFF).await);
|
||||
let rec = body["auctionInfo"][0].clone();
|
||||
let mut got: Vec<&str> = rec
|
||||
.as_object()
|
||||
@@ -1283,7 +1389,7 @@ mod tests {
|
||||
seed_listing(&store, "900000031", 2500).await;
|
||||
|
||||
// No filter: answer with the player's own active pile.
|
||||
let all = parse(&handle_market_status(None, &econ, &store).await);
|
||||
let all = parse(&handle_market_status(None, &econ, &store, SoldExperiment::OFF).await);
|
||||
assert_eq!(
|
||||
all["auctionInfo"].as_array().unwrap().len(),
|
||||
1,
|
||||
@@ -1296,12 +1402,28 @@ mod tests {
|
||||
assert!(all["credits"].is_i64());
|
||||
|
||||
// Explicit tradeIds filter returns exactly the requested auction.
|
||||
let one = parse(&handle_market_status(Some("tradeIds=900000031"), &econ, &store).await);
|
||||
let one = parse(
|
||||
&handle_market_status(
|
||||
Some("tradeIds=900000031"),
|
||||
&econ,
|
||||
&store,
|
||||
SoldExperiment::OFF,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
assert_eq!(one["auctionInfo"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(one["auctionInfo"][0]["tradeId"], 900_000_031i64);
|
||||
|
||||
// An unknown id is absent, not an error: the poll must never fail closed.
|
||||
let miss = parse(&handle_market_status(Some("tradeIds=900000099"), &econ, &store).await);
|
||||
let miss = parse(
|
||||
&handle_market_status(
|
||||
Some("tradeIds=900000099"),
|
||||
&econ,
|
||||
&store,
|
||||
SoldExperiment::OFF,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
assert_eq!(miss["auctionInfo"].as_array().unwrap().len(), 0);
|
||||
|
||||
// Garbage is skipped rather than poisoning the whole poll.
|
||||
@@ -1322,7 +1444,13 @@ mod tests {
|
||||
let trade_id = TRADE_ID_BASE + 100_000_122;
|
||||
|
||||
let unknown = parse(
|
||||
&handle_market_status(Some(&format!("tradeIds={trade_id}")), &econ, &store).await,
|
||||
&handle_market_status(
|
||||
Some(&format!("tradeIds={trade_id}")),
|
||||
&econ,
|
||||
&store,
|
||||
SoldExperiment::OFF,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
assert_eq!(
|
||||
unknown["auctionInfo"].as_array().unwrap().len(),
|
||||
@@ -1347,7 +1475,13 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let one = parse(
|
||||
&handle_market_status(Some(&format!("tradeIds={trade_id}")), &econ, &store).await,
|
||||
&handle_market_status(
|
||||
Some(&format!("tradeIds={trade_id}")),
|
||||
&econ,
|
||||
&store,
|
||||
SoldExperiment::OFF,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
let recs = one["auctionInfo"].as_array().unwrap();
|
||||
assert_eq!(recs.len(), 1, "now there is a real auction to answer with");
|
||||
@@ -1640,4 +1774,249 @@ mod tests {
|
||||
Some("purchased")
|
||||
);
|
||||
}
|
||||
|
||||
// ── staging sold-row experiment ──────────────────────────────────────────
|
||||
//
|
||||
// The client-facing question these support: for a `closed` row, CardsDLL's
|
||||
// native flags cannot distinguish bidState `highest` from `buyNow`
|
||||
// (IS_GLOW = bidState != none, INBOX = bidState in {highest,buyNow}), but the
|
||||
// movie receives bidState verbatim as YOURBID. So the A/B is only meaningful if
|
||||
// EVERY other field is identical between variants. These tests pin that.
|
||||
|
||||
async fn seed_sold(store: &MarketStore, id: &str, buy_now: i64) {
|
||||
seed_listing(store, id, buy_now).await;
|
||||
assert!(store.mark_sold(id).await.unwrap(), "listing became sold");
|
||||
}
|
||||
|
||||
/// Production default: a sold listing is INVISIBLE to the seller's pile and the
|
||||
/// counts stay exactly as they ship today. Guards the Fix A invariant against
|
||||
/// the experiment leaking into production.
|
||||
#[tokio::test]
|
||||
async fn experiment_off_hides_sold_rows_entirely() {
|
||||
let (store, _d) = store_at("soldoff").await;
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
seed_sold(&store, "900000200", 150).await;
|
||||
|
||||
let pile = parse(&handle_market_query("active", &econ, &store, SoldExperiment::OFF).await);
|
||||
assert_eq!(
|
||||
pile["auctionInfo"].as_array().unwrap().len(),
|
||||
0,
|
||||
"no sold row"
|
||||
);
|
||||
assert_eq!(pile["total"], 0);
|
||||
|
||||
let counts = parse(&handle_market_counts(&store, SoldExperiment::OFF).await);
|
||||
assert_eq!(counts["sold"], 0, "production reports sold: 0");
|
||||
assert_eq!(counts["selling"], 0);
|
||||
assert_eq!(counts["count"], 0);
|
||||
|
||||
let status = parse(&handle_market_status(None, &econ, &store, SoldExperiment::OFF).await);
|
||||
assert_eq!(status["auctionInfo"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
/// With the experiment on, the sold row appears and carries the token under
|
||||
/// test, `tradeState: closed`, and `currentBid` = the sale price.
|
||||
#[tokio::test]
|
||||
async fn experiment_projects_the_sold_row_with_the_token_under_test() {
|
||||
for token in ["highest", "buyNow"] {
|
||||
let (store, _d) = store_at(&format!("soldon{token}")).await;
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
seed_sold(&store, "900000201", 150).await;
|
||||
let exp = SoldExperiment::from_values(Some(token), None, None);
|
||||
|
||||
let pile = parse(&handle_market_query("active", &econ, &store, exp).await);
|
||||
let recs = pile["auctionInfo"].as_array().unwrap();
|
||||
assert_eq!(recs.len(), 1, "{token}: the sold row is shown");
|
||||
assert_eq!(recs[0]["tradeState"], "closed", "{token}");
|
||||
assert_eq!(recs[0]["bidState"], token, "{token}");
|
||||
assert_eq!(recs[0]["currentBid"], 150, "{token}: sale price");
|
||||
assert_eq!(
|
||||
recs[0]["expires"], 0,
|
||||
"{token}: a sold auction has no clock"
|
||||
);
|
||||
assert_eq!(pile["total"], 1, "{token}");
|
||||
}
|
||||
}
|
||||
|
||||
/// THE experimental control: between the two variants, EXACTLY ONE field may
|
||||
/// differ. If anything else moves, the client's reaction is not attributable to
|
||||
/// the token and the whole A/B is void.
|
||||
#[tokio::test]
|
||||
async fn the_two_variants_differ_in_bidstate_and_nothing_else() {
|
||||
let mut rows = Vec::new();
|
||||
for token in ["highest", "buyNow"] {
|
||||
let (store, _d) = store_at(&format!("soldab{token}")).await;
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
seed_sold(&store, "900000202", 150).await;
|
||||
let exp = SoldExperiment::from_values(Some(token), None, None);
|
||||
let pile = parse(&handle_market_query("active", &econ, &store, exp).await);
|
||||
rows.push(pile["auctionInfo"][0].clone());
|
||||
}
|
||||
let (a, b) = (&rows[0], &rows[1]);
|
||||
let keys: Vec<&String> = a.as_object().unwrap().keys().collect();
|
||||
let differing: Vec<&&String> = keys
|
||||
.iter()
|
||||
.filter(|k| a[k.as_str()] != b[k.as_str()])
|
||||
.collect();
|
||||
assert_eq!(
|
||||
differing.len(),
|
||||
1,
|
||||
"exactly one field may differ between variants, saw {differing:?}"
|
||||
);
|
||||
assert_eq!(differing[0].as_str(), "bidState");
|
||||
// And the twelve-atom shape is preserved in both.
|
||||
assert_eq!(a.as_object().unwrap().len(), 12, "still twelve atoms");
|
||||
assert_eq!(b.as_object().unwrap().len(), 12);
|
||||
}
|
||||
|
||||
/// `coinsProcessed` (Flash `COINS_AWARDED`) is varied INDEPENDENTLY of the
|
||||
/// bidState A/B, so the third pass cannot be confounded with the first.
|
||||
#[tokio::test]
|
||||
async fn coins_processed_varies_alone() {
|
||||
let mut rows = Vec::new();
|
||||
for cp in [None, Some("1")] {
|
||||
let (store, _d) = store_at(&format!("soldcp{}", cp.unwrap_or("0"))).await;
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
seed_sold(&store, "900000203", 150).await;
|
||||
let exp = SoldExperiment::from_values(Some("highest"), cp, None);
|
||||
let pile = parse(&handle_market_query("active", &econ, &store, exp).await);
|
||||
rows.push(pile["auctionInfo"][0].clone());
|
||||
}
|
||||
assert_eq!(rows[0]["coinsProcessed"], 0);
|
||||
assert_eq!(rows[1]["coinsProcessed"], 1);
|
||||
let keys: Vec<&String> = rows[0].as_object().unwrap().keys().collect();
|
||||
let differing: Vec<&&String> = keys
|
||||
.iter()
|
||||
.filter(|k| rows[0][k.as_str()] != rows[1][k.as_str()])
|
||||
.collect();
|
||||
assert_eq!(
|
||||
differing.len(),
|
||||
1,
|
||||
"only coinsProcessed may move: {differing:?}"
|
||||
);
|
||||
assert_eq!(differing[0].as_str(), "coinsProcessed");
|
||||
}
|
||||
|
||||
/// Counts with a sold row present, under both `count` modes. `count`'s FIFA 17
|
||||
/// meaning is unknown, so it is a controlled variable — never a guess.
|
||||
#[tokio::test]
|
||||
async fn counts_report_sold_and_count_mode_is_controlled() {
|
||||
let (store, _d) = store_at("soldcounts").await;
|
||||
seed_sold(&store, "900000204", 150).await;
|
||||
seed_listing(&store, "900000205", 500).await; // one still active
|
||||
|
||||
let active_mode = SoldExperiment::from_values(Some("highest"), None, Some("active"));
|
||||
let c = parse(&handle_market_counts(&store, active_mode).await);
|
||||
assert_eq!(c["selling"], 1, "one live auction");
|
||||
assert_eq!(c["sold"], 1, "one uncleared sale");
|
||||
assert_eq!(c["count"], 1, "active mode: count == selling");
|
||||
assert_eq!(c["maxAuctionsAllowed"], 100);
|
||||
assert_eq!(c["offered"], 0);
|
||||
|
||||
let both = SoldExperiment::from_values(Some("highest"), None, Some("active_plus_sold"));
|
||||
let c2 = parse(&handle_market_counts(&store, both).await);
|
||||
assert_eq!(c2["selling"], 1);
|
||||
assert_eq!(c2["sold"], 1);
|
||||
assert_eq!(c2["count"], 2, "membership mode: count == selling + sold");
|
||||
}
|
||||
|
||||
/// The bulk clear verb clears sold rows and nothing else, and it is
|
||||
/// PRESENTATION ONLY: it must not touch the economy or resurrect ownership.
|
||||
#[tokio::test]
|
||||
async fn clear_sold_removes_only_sold_rows_and_moves_no_coins() {
|
||||
let (store, _d) = store_at("soldclear").await;
|
||||
let econ = CountingEconomy::with_balance(7_777);
|
||||
seed_sold(&store, "900000206", 150).await;
|
||||
seed_listing(&store, "900000207", 500).await;
|
||||
let exp = SoldExperiment::from_values(Some("highest"), None, None);
|
||||
|
||||
assert_eq!(store.uncleared_sold().await.unwrap().len(), 1);
|
||||
let resp = handle_market_clear_sold(&store).await;
|
||||
assert_eq!(parse(&resp), json!({}), "the client parses nothing");
|
||||
|
||||
assert_eq!(store.uncleared_sold().await.unwrap().len(), 0, "cleared");
|
||||
// The active auction is untouched, and the sold LISTING still exists as
|
||||
// history — clearing is an acknowledgement, not a deletion of the sale.
|
||||
assert_eq!(store.query_listings("active").await.unwrap().len(), 1);
|
||||
assert_eq!(store.get_listing("900000206").await.unwrap().state, "sold");
|
||||
let pile = parse(&handle_market_query("active", &econ, &store, exp).await);
|
||||
assert_eq!(
|
||||
pile["auctionInfo"].as_array().unwrap().len(),
|
||||
1,
|
||||
"only the active one"
|
||||
);
|
||||
let c = parse(&handle_market_counts(&store, exp).await);
|
||||
assert_eq!(c["sold"], 0, "the sold bucket empties on clear");
|
||||
assert_eq!(
|
||||
econ.purchase_calls.load(Ordering::SeqCst),
|
||||
0,
|
||||
"no economy call"
|
||||
);
|
||||
assert_eq!(econ.balance().unwrap(), 7_777, "clearing moves no coins");
|
||||
}
|
||||
|
||||
/// Clearing twice must be a no-op, because the client may retry.
|
||||
#[tokio::test]
|
||||
async fn clearing_sold_twice_is_idempotent() {
|
||||
let (store, _d) = store_at("soldclear2").await;
|
||||
seed_sold(&store, "900000208", 150).await;
|
||||
assert_eq!(
|
||||
store.clear_sold().await.unwrap(),
|
||||
1,
|
||||
"first clear does work"
|
||||
);
|
||||
assert_eq!(
|
||||
store.clear_sold().await.unwrap(),
|
||||
0,
|
||||
"second clears nothing"
|
||||
);
|
||||
assert_eq!(store.get_listing("900000208").await.unwrap().state, "sold");
|
||||
}
|
||||
|
||||
/// A sold row must also answer its own status poll, or the Transfer List would
|
||||
/// render a row from /tradePile and then be told it does not exist.
|
||||
#[tokio::test]
|
||||
async fn sold_row_answers_its_status_poll() {
|
||||
let (store, _d) = store_at("soldstatus").await;
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
seed_sold(&store, "900000209", 150).await;
|
||||
let exp = SoldExperiment::from_values(Some("buyNow"), Some("1"), None);
|
||||
|
||||
let one =
|
||||
parse(&handle_market_status(Some("tradeIds=900000209"), &econ, &store, exp).await);
|
||||
let recs = one["auctionInfo"].as_array().unwrap();
|
||||
assert_eq!(recs.len(), 1);
|
||||
assert_eq!(recs[0]["tradeState"], "closed");
|
||||
assert_eq!(recs[0]["bidState"], "buyNow");
|
||||
assert_eq!(recs[0]["coinsProcessed"], 1);
|
||||
|
||||
let all = parse(&handle_market_status(None, &econ, &store, exp).await);
|
||||
assert_eq!(
|
||||
all["auctionInfo"].as_array().unwrap().len(),
|
||||
1,
|
||||
"unfiltered too"
|
||||
);
|
||||
}
|
||||
|
||||
/// `mark_sold` is the sale transition and must happen at most once, so a
|
||||
/// duplicated counterparty settlement cannot double-sell.
|
||||
#[tokio::test]
|
||||
async fn mark_sold_is_once_only() {
|
||||
let (store, _d) = store_at("soldonce").await;
|
||||
seed_listing(&store, "900000210", 150).await;
|
||||
assert!(store.mark_sold("900000210").await.unwrap(), "first wins");
|
||||
assert!(
|
||||
!store.mark_sold("900000210").await.unwrap(),
|
||||
"second is refused"
|
||||
);
|
||||
assert!(
|
||||
!store.mark_sold("nosuchlisting").await.unwrap(),
|
||||
"unknown id"
|
||||
);
|
||||
assert_eq!(
|
||||
store.uncleared_sold().await.unwrap().len(),
|
||||
1,
|
||||
"one sold row"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user