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:
@@ -0,0 +1,189 @@
|
||||
//! STAGING-ONLY: complete a market sale on behalf of a synthetic Buyer B.
|
||||
//!
|
||||
//! Production has no trigger that decides "your listing sold" — that needs the
|
||||
//! seller-facing sold wire contract, which is exactly what the staging experiment
|
||||
//! is trying to establish. This binary is the synthetic counterparty: it runs the
|
||||
//! REAL settlement path (`CoreEconomy::settle_sale` → Core's atomic
|
||||
//! `POST /economy/settle-sale`) and then flips the host's listing to `sold`, so the
|
||||
//! seller's client sees an authentic completed sale rather than a hand-written row.
|
||||
//!
|
||||
//! It is a separate binary precisely so no production HTTP surface grows an
|
||||
//! experiment hook. It is never deployed and never runs in production.
|
||||
//!
|
||||
//! Ordering is deliberate: **settle first, mark sold second.** If settlement fails
|
||||
//! the listing stays live and nothing has moved. If marking fails after a
|
||||
//! successful settlement, the coins and ownership are already correct and the
|
||||
//! listing is merely still shown as active — recoverable, and it never pays twice
|
||||
//! because `mark_sold` is a once-only transition and clearing is presentation-only.
|
||||
//!
|
||||
//! ```text
|
||||
//! staging-sell --market-db PATH --core-url URL --trade-id ID \
|
||||
//! --item CORE_ITEM_ID --seller CLUB --buyer CLUB [--gross N]
|
||||
//! ```
|
||||
//! `--gross` defaults to 150, the canonical staging sale.
|
||||
|
||||
use openfut_utas_host::market_store::MarketStore;
|
||||
use openfut_utas_host::{CoreEconomy, EconomySale, HttpCoreClient};
|
||||
|
||||
/// FIFA 17's transfer fee, taken from the adapter so this harness can never
|
||||
/// disagree with the shipped policy about what the seller is owed.
|
||||
fn fee_for(gross: i64) -> i64 {
|
||||
openfut_adapter_fifa17::fut::economy_policy::transfer_market_fee(gross)
|
||||
}
|
||||
|
||||
struct Args {
|
||||
market_db: String,
|
||||
core_url: String,
|
||||
trade_id: String,
|
||||
item: String,
|
||||
seller: String,
|
||||
buyer: String,
|
||||
gross: i64,
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Args, String> {
|
||||
let mut market_db = None;
|
||||
let mut core_url = None;
|
||||
let mut trade_id = None;
|
||||
let mut item = None;
|
||||
let mut seller = None;
|
||||
let mut buyer = None;
|
||||
let mut gross = 150i64;
|
||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut i = 0;
|
||||
while i < argv.len() {
|
||||
let need = |i: usize| -> Result<String, String> {
|
||||
argv.get(i + 1)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("{} needs a value", argv[i]))
|
||||
};
|
||||
match argv[i].as_str() {
|
||||
"--market-db" => market_db = Some(need(i)?),
|
||||
"--core-url" => core_url = Some(need(i)?),
|
||||
"--trade-id" => trade_id = Some(need(i)?),
|
||||
"--item" => item = Some(need(i)?),
|
||||
"--seller" => seller = Some(need(i)?),
|
||||
"--buyer" => buyer = Some(need(i)?),
|
||||
"--gross" => gross = need(i)?.parse().map_err(|e| format!("--gross: {e}"))?,
|
||||
other => return Err(format!("unknown argument {other}")),
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
Ok(Args {
|
||||
market_db: market_db.ok_or("--market-db is required")?,
|
||||
core_url: core_url.ok_or("--core-url is required")?,
|
||||
trade_id: trade_id.ok_or("--trade-id is required")?,
|
||||
item: item.ok_or("--item is required")?,
|
||||
seller: seller.ok_or("--seller is required")?,
|
||||
buyer: buyer.ok_or("--buyer is required")?,
|
||||
gross,
|
||||
})
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = match parse_args() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
eprintln!("staging-sell: {e}");
|
||||
eprintln!(
|
||||
"usage: staging-sell --market-db PATH --core-url URL --trade-id ID \
|
||||
--item CORE_ITEM_ID --seller CLUB --buyer CLUB [--gross N]"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
|
||||
// Refuse to run against anything that looks like production state. This binary
|
||||
// exists to keep an experiment isolated, so the guard belongs here rather than
|
||||
// only in the caller.
|
||||
for (label, value) in [
|
||||
("--market-db", &args.market_db),
|
||||
("--core-url", &args.core_url),
|
||||
] {
|
||||
if value.contains("openfut-promotion")
|
||||
|| value.contains(":18080")
|
||||
|| value.contains(":8099")
|
||||
{
|
||||
eprintln!("staging-sell: REFUSING to touch production via {label}={value}");
|
||||
std::process::exit(3);
|
||||
}
|
||||
}
|
||||
|
||||
let fee = fee_for(args.gross);
|
||||
let proceeds = args.gross - fee;
|
||||
println!(
|
||||
"staging-sell: gross={} fee={} proceeds={} (floor 5%, fee+proceeds==gross)",
|
||||
args.gross, fee, proceeds
|
||||
);
|
||||
|
||||
let core = HttpCoreClient::new(args.core_url.clone(), "fifa17");
|
||||
let sale = EconomySale {
|
||||
item_id: &args.item,
|
||||
seller_club_id: Some(&args.seller),
|
||||
buyer_club_id: Some(&args.buyer),
|
||||
gross: args.gross,
|
||||
fee,
|
||||
};
|
||||
|
||||
// 1. Settle atomically in Core: buyer debited, item transferred, seller paid net.
|
||||
let receipt = match core.settle_sale(&sale) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("staging-sell: settlement FAILED, listing left live: {e:?}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
println!(
|
||||
"staging-sell: SETTLED item={} card={} seller={} -> buyer={:?} \
|
||||
seller_balance={} buyer_balance={:?} squad_slots_freed={}",
|
||||
receipt.item_id,
|
||||
receipt.card_id,
|
||||
receipt.seller_club_id,
|
||||
receipt.buyer_club_id,
|
||||
receipt.seller_balance,
|
||||
receipt.buyer_balance,
|
||||
receipt.squad_slots_freed
|
||||
);
|
||||
|
||||
// 2. Only now does the seller's listing become `sold`, so the client can be
|
||||
// shown a completed sale.
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
eprintln!("staging-sell: runtime: {e} (settlement already committed)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
rt.block_on(async {
|
||||
let store = match MarketStore::open(&args.market_db).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"staging-sell: market store {} failed to open: {e} \
|
||||
(settlement already committed — coins/ownership are correct)",
|
||||
args.market_db
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
match store.mark_sold(&args.trade_id).await {
|
||||
Ok(true) => println!("staging-sell: listing {} -> sold", args.trade_id),
|
||||
Ok(false) => println!(
|
||||
"staging-sell: listing {} was NOT live (already sold/cancelled) — \
|
||||
no second sale, nothing changed",
|
||||
args.trade_id
|
||||
),
|
||||
Err(e) => {
|
||||
eprintln!("staging-sell: mark_sold failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
match store.uncleared_sold().await {
|
||||
Ok(rows) => println!("staging-sell: uncleared sold rows now {}", rows.len()),
|
||||
Err(e) => eprintln!("staging-sell: uncleared_sold read failed: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user