Created
April 5, 2021 10:23
-
-
Save teryror/9663e7b2577cc3c8a7edb7218a095269 to your computer and use it in GitHub Desktop.
Finding the win rates required to achieve rare card price parity with the MTG Arena Store
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
fn binomial_coefficient(n: u32, k: u32) -> u128 { | |
let mut res = 1u128; | |
for i in 0..k { | |
res = (res * (n - i) as u128) / (i + 1) as u128; | |
} | |
res | |
} | |
fn neg_binom_pmf(k: u32, r: u32, p: f64) -> f64 { | |
assert!(r > 0); | |
binomial_coefficient(k + r - 1, k) as f64 * (1.0 - p).powi(r as i32) * p.powi(k as i32) | |
} | |
fn binomial_pmf(k: u32, n: u32, p: f64) -> f64 { | |
binomial_coefficient(n, k) as f64 * p.powi(k as i32) * (1.0 - p).powi((n - k) as i32) | |
} | |
#[derive(Copy, Clone, PartialEq)] | |
enum MatchType { | |
BestOfOne, | |
BestOfThree, | |
} | |
impl MatchType { | |
fn match_win_rate(&self, game_win_rate: f64) -> f64 { | |
match self { | |
&Self::BestOfOne => game_win_rate, | |
&Self::BestOfThree => 3.0 * game_win_rate.powi(2) - 2.0 * game_win_rate.powi(3), | |
} | |
} | |
} | |
#[derive(Copy, Clone)] | |
enum EndCondition { | |
NMatches, | |
NWinsOrRLosses(u32) | |
} | |
#[derive(Copy, Clone)] | |
enum Currency { | |
Gold, | |
Gems | |
} | |
impl Currency { | |
fn store_price_per_rare(self) -> f64 { | |
let rares_per_pack = 1.0 + (1.0 / 6.0); | |
match self { | |
Self::Gold => 1000.0 / rares_per_pack, | |
Self::Gems => 200.0 / rares_per_pack, | |
} | |
} | |
} | |
struct Event { | |
name: &'static str, | |
entry_fee_gems: u32, | |
entry_fee_gold: u32, | |
currency: Currency, | |
style: MatchType, | |
limited: bool, | |
end_con: EndCondition, | |
payouts: &'static [u32], | |
rares: &'static [f32], | |
} | |
impl Event { | |
fn constructed(name: &'static str, entry_fee_gems: u32, entry_fee_gold: u32, currency: Currency, style: MatchType, end_con: EndCondition, payouts: &'static [u32], rares: &'static [f32]) -> Self { | |
assert_eq!(payouts.len(), rares.len()); | |
Event { | |
name, entry_fee_gems, entry_fee_gold, currency, style, limited: false, end_con, payouts, rares | |
} | |
} | |
fn limited(name: &'static str, entry_fee_gems: u32, entry_fee_gold: u32, currency: Currency, style: MatchType, end_con: EndCondition, payouts: &'static [u32], rares: &'static [f32]) -> Self { | |
assert_eq!(payouts.len(), rares.len()); | |
Event { | |
name, entry_fee_gems, entry_fee_gold, currency, style, limited: true, end_con, payouts, rares | |
} | |
} | |
fn expected_effective_price_per_rare(&self, game_win_rate: f64, entry_currency: Currency) -> f64 { | |
let p_win = self.style.match_win_rate(game_win_rate); | |
let mut expected_payout = 0.0; | |
let mut expected_rares = if self.limited { | |
if self.name == "Sealed Deck" { 6.0 } else { 3.0 } | |
} else { | |
0.0 | |
}; | |
match self.end_con { | |
EndCondition::NWinsOrRLosses(r) => { | |
let mut cumulative_p = 0.0; | |
for k in 0..self.payouts.len() - 1 { | |
let p = neg_binom_pmf(k as u32, r, p_win); | |
expected_payout += p * self.payouts[k] as f64; | |
expected_rares += p * self.rares[k] as f64; | |
cumulative_p += p; | |
} | |
let p_n_wins = 1.0 - cumulative_p; | |
expected_payout += p_n_wins * *self.payouts.last().unwrap() as f64; | |
expected_rares += p_n_wins * *self.rares.last().unwrap() as f64; | |
}, | |
EndCondition::NMatches => { | |
let n = self.payouts.len() - 1; | |
for k in 0..=n { | |
let p = binomial_pmf(k as u32, n as u32, p_win); | |
expected_payout += p * self.payouts[k] as f64; | |
expected_rares += p * self.rares[k] as f64; | |
} | |
} | |
} | |
let entry_fee = match self.currency { | |
Currency::Gems => self.entry_fee_gems as f64, | |
Currency::Gold => self.entry_fee_gold as f64, | |
}; | |
// These EVs are for a single run, but if we spend the currency rewards | |
// on future events, and only care about the rares, each run pays for | |
// a fraction of a future run, so we can say that | |
// | |
// EV_rares_total = EV_rares_single + (EV_gems / Fee_gems) * EV_rares_total | |
// <=> | |
// EV_rares_total - (EV_gems / Fee_gems) * EV_rares_total = EV_rares_single | |
// <=> | |
// (1 - EV_gems / Fee_gems) * EV_rares_total = EV_rares_single | |
// <=> | |
// EV_rares_total = EV_rares_single / (1 - EV_gems / Fee_gems) | |
let expected_rares_total = expected_rares / (1.0 - expected_payout / entry_fee); | |
let entry_fee = match entry_currency { | |
Currency::Gold => self.entry_fee_gold as f64, | |
Currency::Gems => self.entry_fee_gems as f64, | |
}; | |
entry_fee / expected_rares_total | |
} | |
fn break_even_point(&self, entry_currency: Currency) -> f64 { | |
let target = entry_currency.store_price_per_rare(); | |
let mut p = (0.0f64, 1.0f64); | |
while (p.0 - p.1).abs() > 0.001 { | |
let p_mid = (p.0 + p.1) / 2.0; | |
let ev = self.expected_effective_price_per_rare(p_mid, entry_currency); | |
assert!(ev.is_finite()); | |
if ev > target { | |
p.0 = p_mid; | |
} else if ev < target { | |
p.1 = p_mid; | |
} else { | |
return p_mid; | |
} | |
} | |
(p.0 + p.1) / 2.0 | |
} | |
} | |
fn main() { | |
use MatchType::*; | |
use EndCondition::*; | |
use Currency::*; | |
const pack: f32 = 1.0 + (1.0 / 6.0); | |
let event_types = [ | |
Event::limited("Quick Draft", 750, 5000, Gems, BestOfOne, NWinsOrRLosses(3), &[50, 100, 200, 300, 450, 650, 850, 950], &[1.2 * pack, 1.22 * pack, 1.24 * pack, 1.26 * pack, 1.3 * pack, 1.35 * pack, 1.4 * pack, 2.0 * pack]), | |
Event::limited("Sealed Deck", 2000, std::u32::MAX, Gems, BestOfOne, NWinsOrRLosses(3), &[200, 400, 600, 1200, 1400, 1600, 2000, 2200], &[3.0 * pack, 3.0 * pack, 3.0 * pack, 3.0 * pack, 3.0 * pack, 3.0 * pack, 3.0 * pack, 3.0 * pack]), | |
Event::constructed("Standard Event", 95, 500, Gold, BestOfOne, NWinsOrRLosses(3), &[100, 200, 300, 400, 500, 600, 800, 1000], &[0.07, 0.07, 0.07, 0.07, 0.07, 1.06, 2.05, 2.05]), | |
Event::constructed("Traditional Event", 190, 1000, Gold, BestOfThree, NWinsOrRLosses(2), &[0, 500, 1000, 1500, 1700, 2100], &[0.15, 0.25, 0.25, 1.2, 1.2, 2.15]), | |
Event::limited("Premier Draft", 1500, 10000, Gems, BestOfOne, NWinsOrRLosses(3), &[50, 100, 250, 1000, 1400, 1600, 1800, 2200], &[pack, pack, 2.0 * pack, 2.0 * pack, 3.0 * pack, 4.0 * pack, 5.0 * pack, 6.0 * pack]), | |
Event::limited("Traditional Draft", 1500, 10000, Gems, BestOfThree, NMatches, &[0, 0, 1000, 3000], &[pack, pack, 4.0 * pack, 6.0 * pack]), | |
Event::constructed("Historic Challenge", 2000, 10000, Gold, BestOfThree, NWinsOrRLosses(3), &[0, 1000, 2000, 3000, 4000, 6000, 8000, 10000, 15000], &[4.0, 4.0, 4.0, 4.0, 4.0, 8.0 * pack, 12.0 * pack, 20.0 * pack, 40.0 * pack]), | |
Event::constructed("Traditional Cube", 600, 4000, Gold, BestOfThree, NMatches, &[0, 0, 4000, 6000], &[1.0, 1.0, 1.0, 2.0]), | |
Event::constructed("Arena Cube Draft", 600, 4000, Gold, BestOfOne, NWinsOrRLosses(3), &[0, 500, 1000, 2000, 3000, 4000, 5000, 6000], &[1.0, 1.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0]), | |
]; | |
for event in &event_types { | |
let p_win_to_beat_store_gold = event.break_even_point(Gold); | |
let p_win_to_beat_store_gems = event.break_even_point(Gems); | |
print!("{:18}:{:5.1}%", event.name, p_win_to_beat_store_gems * 100.0); | |
if event.style == BestOfThree { | |
let match_win_rate = BestOfThree.match_win_rate(p_win_to_beat_store_gems); | |
print!(" ({:4.1}%)", match_win_rate * 100.0); | |
} else { | |
print!(" "); | |
} | |
print!(" :{:5.1}%", p_win_to_beat_store_gold * 100.0); | |
if event.style == BestOfThree { | |
let match_win_rate = BestOfThree.match_win_rate(p_win_to_beat_store_gold); | |
print!(" ({:4.1}%)", match_win_rate * 100.0); | |
} | |
println!(); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment