Skip to content

Instantly share code, notes, and snippets.

@hexparrot
Created May 10, 2019 00:02
Show Gist options
  • Save hexparrot/75ef8c47217175859b56769cde2ced0f to your computer and use it in GitHub Desktop.
Save hexparrot/75ef8c47217175859b56769cde2ced0f to your computer and use it in GitHub Desktop.
MTG:A Opening Hand Land Simulator
#!/usr/bin/env python3
__author__ = "Will Dizon"
__license__ = "Public Domain"
__version__ = "0.0.1"
__email__ = "wdchromium@gmail.com"
import numpy as np
from scipy.stats import hypergeom
from collections import Counter
# variables
TRIALS = 100000
LANDS_IN_DECK = 17
LIBRARY_SIZE = 40
INITIAL_HAND_SIZE = 7
# calculations
land_composition = float(LANDS_IN_DECK)/float(LIBRARY_SIZE)
expected_land = (land_composition * INITIAL_HAND_SIZE)
[M, n, N] = [LIBRARY_SIZE, LANDS_IN_DECK, INITIAL_HAND_SIZE]
rv = hypergeom(M, n, N)
x = np.arange(0, n+1)
pmf_cards = rv.pmf(x)
std_deviation = hypergeom.std(M, n, N)
print('--')
print('Theoretical distribution of hand in initial hand size of %i' % INITIAL_HAND_SIZE)
print("Expected land composition: %.2f" % expected_land)
for i in range(0, INITIAL_HAND_SIZE+1):
print("%i land: %7.3f%%" % (i, pmf_cards[i] * 100))
#prb = hypergeom.cdf(x, M, n, N)
#print(prb)
print('--')
print('Simulated distribution over %i trials, single-hand draw ("true random")' % (TRIALS))
R = hypergeom.rvs(M, n, N, size=TRIALS)
unique, counts = np.unique(R, return_counts=True)
dist = dict(zip(unique, counts))
print(dist)
print('')
for i in range(0, INITIAL_HAND_SIZE+1):
pct = float(dist[i]/float(TRIALS))
try:
difference_pct = (pct - pmf_cards[i])*100
print("%i land: %7.3f%% (diff: %7.3f%%)" % (i, pct * 100, difference_pct))
except KeyError:
print("%i land: %7.3f%%" % (i, 0))
print('--')
print('Simulated distribution over %i trials, double-hand draw' % (TRIALS))
print('Deterministically choosing hand closer to expected land composition')
print("Selects based on min distance from expected land composition (%.2f land)" % expected_land)
R_1 = hypergeom.rvs(M, n, N, size=TRIALS)
R_2 = hypergeom.rvs(M, n, N, size=TRIALS)
unique_1, counts_1 = np.unique(R_1, return_counts=True)
unique_2, counts_2 = np.unique(R_2, return_counts=True)
dist_1 = dict(zip(unique_1, counts_1))
dist_2 = dict(zip(unique_2, counts_2))
print(dist_1)
print(dist_2)
print('')
cnt = Counter()
for pair in zip(R_1,R_2):
diff_1 = abs(expected_land - pair[0])
diff_2 = abs(expected_land - pair[1])
if diff_1 <= diff_2:
cnt[pair[0]] += 1
else:
cnt[pair[1]] += 1
for i in range(0, INITIAL_HAND_SIZE+1):
pct = float(cnt[i]/float(TRIALS))
try:
difference_pct = (pct - pmf_cards[i])*100
print("%i land: %7.3f%% (diff: %7.3f%%)" % (i, pct * 100, difference_pct))
except KeyError:
print("%i land: %7.3f%%" % (i, 0))
print('--')
print('Simulated distribution over %i trials, double-hand draw' % (TRIALS))
print("Random selection of hand WEIGHTED TOWARD expected land composition (%.2f land)" % expected_land)
R_1 = hypergeom.rvs(M, n, N, size=TRIALS)
R_2 = hypergeom.rvs(M, n, N, size=TRIALS)
unique_1, counts_1 = np.unique(R_1, return_counts=True)
unique_2, counts_2 = np.unique(R_2, return_counts=True)
dist_1 = dict(zip(unique_1, counts_1))
dist_2 = dict(zip(unique_2, counts_2))
print(dist_1)
print(dist_2)
print('')
cnt = Counter()
for pair in zip(R_1,R_2):
'''
how this works-
Disclaimer: I am not asserting that this is the methodology used
by MTG:A for the hand-draw algorithm, but rather this is a
demonstration that with very little effort, opening hands can be
tweaked to slightly reduce unforgivingly bad hands and to add
that probabilities toward the DESIRED land composition. This
method does not--in any way--favor giving the player "exactly 3"
or any precise number. This method works specifically so that
no matter how many lands you include in your deck, that proportion
is more-likely represented by your opening hand at the start
of the game. As stated by the WotC mod Godot, this simultaneously
accomplishes the following goals:
- reduce the frequency of mulligans without incentivizing mana-base
construction outside the strategic norms of the game
- leans towards giving the player the hand with the mix of spells and
lands (without regard for color) closest to average for that deck.
https://forums.mtgarena.com/forums/threads/347?page=1
'''
# these variables determine how far from the expected land
# composition the randomly-generated hand is. 17/40=2.98
pct_off_1 = abs(float(expected_land) - float(pair[0])) / float(expected_land)
pct_off_2 = abs(float(expected_land) - float(pair[1])) / float(expected_land)
''' if the first and second hands drawn give you 1 and 3 lands,
respectively, then the pct_off would be:
1 land: 0.6638655462184874
3 land: 0.008403361344537785
That is, 1 land is 66% less than the expected land of 2.98 and
3 lands is ridiculously close at .008% off.
1 - 2.98 = -1.98, turn that into an absolute value (distance) -> 1.98
3 - 2.98 = -0.02, turn that into an absolute value (distance) -> 0.02
~1.98/2.98 = 0.6638655462184874
~0.02/2.98 = 0.008403361344537785
Since this is a percentage, we can subtract it from 100% to
get a meaningful inverse number representing proportion.
The means for weighting is the closer the number, the more likely
it should be picked, which isn't possible with just distance.
1 - 0.6638655462184874 = 0.33613445378151263 (3 land)
1 - 0.008403361344537785 = 0.9915966386554622 (1 land)
Now add these two together for the full range of the random to choose from.
.33613 + .99159 = 1.327731092436975
A random floating point number between 0 and 1.327731092436975 is chosen
from a uniform random function which decides the hand:
If the number is < .33613, hand 1 is chosen, with 1 land offered.
If the number is >= .33613, hand 2 is chosen, with 3 lands offered.
This means that it is still POSSIBLE to get the bad hand, but it is far more
LIKELY that you would get the 3-land hand. In this specific case,
the 3-land is 74.683% likely, and the 1-land hand is the remainder.
Naturally, two hands of equal lands (3,3) or even (5,5) would have the same
distance from the mean, so both would be equally likely (50/50)--although
by natural probabilities, 5-land hands are already particularly unlikely.
'''
diff_1 = (1 - pct_off_1)
diff_2 = (1 - pct_off_2)
selection = np.random.uniform(low=0, high=diff_1+diff_2)
if selection < diff_1:
cnt[pair[0]] += 1
else:
cnt[pair[1]] += 1
for i in range(0, INITIAL_HAND_SIZE+1):
pct = float(cnt[i]/float(TRIALS))
try:
difference_pct = (pct - pmf_cards[i])*100
print("%i land: %7.3f%% (diff: %7.3f%%)" % (i, pct * 100, difference_pct))
except KeyError:
print("%i land: %7.3f%%" % (i, 0))
'''
UNDERSTANDING THE OUTPUT:
Theoretical distribution of hand in initial hand size of 7
Expected land composition: 2.98
[based on land:library size, 2.98 reflects the average amount of land
an opening hand will have. Averages do not need to adhere to discrete
numbers, e.g., 3, 4, 5.]
0 land: 1.315%
1 land: 9.205%
2 land: 24.546%
3 land: 32.297%
4 land: 22.608%
5 land: 8.397%
6 land: 1.527%
7 land: 0.104%
[Assuming true random, generate a number between 0 and 100 and you'll
find out which of these you'd get, most likely 3, 2, then 4 in that order.]
Simulated distribution over 100000 trials, double-hand draw
Random selection of hand WEIGHTED TOWARD expected land composition (2.98 land)
{0: 1222, 1: 9027, 2: 24759, 3: 32095, 4: 22878, 5: 8373, 6: 1541, 7: 105}
{0: 1290, 1: 9246, 2: 24574, 3: 32048, 4: 22919, 5: 8280, 6: 1542, 7: 101}
[These are the current run's actual generated numbers for lands-in-opening hand]
0 land: 0.046% (diff: -1.269%)
1 land: 6.523% (diff: -2.682%)
2 land: 25.456% (diff: 0.910%)
3 land: 38.795% (diff: 6.498%)
4 land: 23.268% (diff: 0.660%)
5 land: 5.881% (diff: -2.516%)
6 land: 0.031% (diff: -1.496%)
7 land: 0.000% (diff: -0.104%)
[Diff indicates how much impact from the theoretical distribution the adjustment made.
Here, over 100000 draws, the player received 1.269% fewer 0-land hands, as in
"here's the hand, do you wish to mulligan?"
The player received .104% fewer 7 land hands, and received 6.498% MORE 3-land hands.
Note, this does not favor ANY meta or land composition: if you adjust the 17/40 lands
to 12/40 lands, you'll see ALL THESE NUMBERS GRAVITATE TOWARD THE NEW "EXPECTED LAND"
value which would be lower at 2.10 land.] Example run:
Simulated distribution over 100000 trials, double-hand draw
Random selection of hand WEIGHTED TOWARD expected land composition (2.10 land)
{0: 6333, 1: 23843, 2: 34758, 3: 24543, 4: 8719, 5: 1669, 6: 130, 7: 5}
{0: 6446, 1: 24265, 2: 34562, 3: 24143, 4: 8777, 5: 1657, 6: 148, 7: 2}
0 land: 0.645% (diff: -5.706%)
1 land: 24.167% (diff: -0.082%)
2 land: 44.870% (diff: 10.078%)
3 land: 26.095% (diff: 1.934%)
4 land: 4.193% (diff: -4.505%)
5 land: 0.030% (diff: -1.576%)
6 land: 0.000% (diff: -0.139%)
7 land: 0.000% (diff: -0.004%)
This is one such approach that would improve best-of-one playability for all players,
regardless of the meta. Main takeaway: all players would more likely get exactly
the number of lands in their opening hand to match the proportion in their deck.
'''
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment