//! 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}"); } }