Skip to content

Instantly share code, notes, and snippets.

@teryror
Created April 5, 2021 10:23
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save teryror/9663e7b2577cc3c8a7edb7218a095269 to your computer and use it in GitHub Desktop.
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
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