Files
OpenFUT/openfut-utas-host/src/sold_experiment.rs
T
funman300 468bc0fba9 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.
2026-08-18 02:14:18 +00:00

198 lines
7.6 KiB
Rust

//! STAGING-ONLY seller-facing SOLD-row experiment.
//!
//! Static RE has exhausted CardsDLL on one question: for a `closed` row,
//! `IS_GLOW = (bidState != none)` and `INBOX = (bidState in {highest, buyNow})`,
//! so `closed/highest` and `closed/buyNow` are **bit-identical** to every native
//! consumer. But `bidState` is also published to the movie verbatim as `YOURBID`,
//! so the FUT ActionScript front end CAN separate them. This module exists to ask
//! the client which one it treats as the seller's sale, by holding every other
//! field constant and changing exactly that token.
//!
//! # Production safety
//!
//! Every knob is OFF unless its environment variable is set explicitly, and
//! [`SoldExperiment::enabled`] gates every projection at the call site. With no
//! env set this module changes nothing: `/tradePile` and `/trade/status` emit only
//! real active auctions (the Fix A invariant) and `/tradePile/counts` reports
//! `sold: 0` exactly as production does today. An unrecognised value is treated as
//! OFF rather than as a default token, because silently picking a token would
//! fabricate the very answer the experiment is meant to measure.
//!
//! # Why this cannot be "discovery"
//!
//! Our server IS the server, so nothing here recovers EA's original contract. It
//! is a controlled discriminator: the client's *reaction* (which bucket it draws
//! the row in, what it counts, and which request it issues to clear it) is the
//! observation.
/// What `/tradePile/counts.count` should report while a sold row exists. FIFA 17's
/// exact semantics for `count` are unknown — it is either the number of live
/// auctions or the whole Transfer List membership — so it is a controlled variable
/// rather than a guess.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CountMode {
/// `count` = active auctions only (current production behaviour).
Active,
/// `count` = active + uncleared sold (Transfer List membership).
ActivePlusSold,
}
/// Resolved experiment configuration.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SoldExperiment {
/// The `bidState` token to emit on a sold seller row. `None` disables every
/// part of the experiment.
pub bid_state: Option<&'static str>,
/// The `coinsProcessed` value to emit (published to Flash as `COINS_AWARDED`).
pub coins_processed: i64,
pub count_mode: CountMode,
}
impl SoldExperiment {
/// All-off. This is what production runs.
pub const OFF: Self = Self {
bid_state: None,
coins_processed: 0,
count_mode: CountMode::Active,
};
/// Read the configuration from the environment.
///
/// * `OPENFUT_FIFA17_SOLD_EXPERIMENT` — `highest` | `buyNow`; anything else
/// (including absent) is OFF.
/// * `OPENFUT_FIFA17_SOLD_COINS_PROCESSED` — `1` to emit 1, else 0.
/// * `OPENFUT_FIFA17_SOLD_COUNT_MODE` — `active_plus_sold`, else `active`.
pub fn from_env() -> Self {
Self::from_values(
std::env::var("OPENFUT_FIFA17_SOLD_EXPERIMENT")
.ok()
.as_deref(),
std::env::var("OPENFUT_FIFA17_SOLD_COINS_PROCESSED")
.ok()
.as_deref(),
std::env::var("OPENFUT_FIFA17_SOLD_COUNT_MODE")
.ok()
.as_deref(),
)
}
/// Pure resolver, so the parsing rules are testable without touching the
/// process environment.
pub fn from_values(
experiment: Option<&str>,
coins_processed: Option<&str>,
count_mode: Option<&str>,
) -> Self {
// Matched case-insensitively for operator convenience, but ONLY the two
// real FIFA 17 tokens are accepted. `none`/`outbid` are deliberately not
// offered: neither can describe a completed sale, and `none` on a closed
// row clears IS_GLOW, which would test nothing.
let bid_state = match experiment.map(str::trim).unwrap_or("") {
s if s.eq_ignore_ascii_case("highest") => Some("highest"),
s if s.eq_ignore_ascii_case("buynow") => Some("buyNow"),
_ => None,
};
Self {
bid_state,
coins_processed: i64::from(coins_processed == Some("1")),
count_mode: match count_mode.map(str::trim).unwrap_or("") {
s if s.eq_ignore_ascii_case("active_plus_sold") => CountMode::ActivePlusSold,
_ => CountMode::Active,
},
}
}
/// Whether any sold projection is active. Production: always false.
pub fn enabled(&self) -> bool {
self.bid_state.is_some()
}
/// A one-line banner for the host's startup log, so a staging run can never be
/// mistaken for a production one in a capture.
pub fn banner(&self) -> String {
match self.bid_state {
None => "sold-experiment=OFF (production behaviour)".to_string(),
Some(b) => format!(
"sold-experiment=ON bidState={b} coinsProcessed={} countMode={:?} \
-- STAGING ONLY, never production",
self.coins_processed, self.count_mode
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn absent_env_is_off_and_matches_production() {
let e = SoldExperiment::from_values(None, None, None);
assert!(!e.enabled());
assert_eq!(e, SoldExperiment::OFF);
assert_eq!(e.coins_processed, 0);
assert_eq!(e.count_mode, CountMode::Active);
}
/// The whole point of the harness: exactly two tokens, and nothing else may
/// turn it on. A typo must not silently select a token and manufacture the
/// answer we are trying to measure.
#[test]
fn only_the_two_real_tokens_enable_it() {
for (input, expected) in [
("highest", Some("highest")),
("HIGHEST", Some("highest")),
("buyNow", Some("buyNow")),
("buynow", Some("buyNow")),
(" highest ", Some("highest")),
("off", None),
("none", None),
("outbid", None),
("closed", None),
("", None),
("hihgest", None), // typo
("1", None),
] {
let e = SoldExperiment::from_values(Some(input), None, None);
assert_eq!(e.bid_state, expected, "input {input:?}");
}
}
#[test]
fn coins_processed_is_strictly_one_or_zero() {
for (input, expected) in [
(Some("1"), 1),
(Some("0"), 0),
(Some("true"), 0), // only "1" means 1 — no fuzzy truthiness
(Some(""), 0),
(None, 0),
] {
assert_eq!(
SoldExperiment::from_values(Some("highest"), input, None).coins_processed,
expected,
"input {input:?}"
);
}
}
#[test]
fn count_mode_defaults_to_production_behaviour() {
let mk = |m| SoldExperiment::from_values(Some("highest"), None, m).count_mode;
assert_eq!(mk(None), CountMode::Active);
assert_eq!(mk(Some("active")), CountMode::Active);
assert_eq!(mk(Some("active_plus_sold")), CountMode::ActivePlusSold);
assert_eq!(mk(Some("ACTIVE_PLUS_SOLD")), CountMode::ActivePlusSold);
assert_eq!(mk(Some("everything")), CountMode::Active, "unknown -> safe");
}
#[test]
fn banner_names_the_variant_under_test() {
assert!(SoldExperiment::OFF.banner().contains("OFF"));
let on = SoldExperiment::from_values(Some("buyNow"), Some("1"), None);
let b = on.banner();
assert!(b.contains("bidState=buyNow"), "{b}");
assert!(b.contains("coinsProcessed=1"), "{b}");
assert!(b.contains("STAGING ONLY"), "{b}");
}
}