This is a little framework to answer statistical questions related to Hearthstone. Things started when I wrote an analysis that gathered some interest on reddit. I then looked into doing some more analysis but noticed that I was doing a lot of copy/pasting. I then decided to write some helpers to avoid repeating myself and make less mistakes.
Many Hearthstone questions can probably be answered with exact formulas. However, that may involve brain power that you might want to invest elsewhere. An alternative solution is to approach the exact answer by running multiple simulations and aggregating the results. Essentially, zugzug
is a tool that helps you perform so-called Monte Carlo experiments. The user provides a simulation function, which takes various parameters as inputs and outputs a number. Then, the user calls zugzug
's run
function and specifies a grid of parameters. The results are then aggregated and displayed in a table.
Various utilities such as cards, conditions, and game mechanisms are implemented in zugzug
to help out writing simulation functions. These utilities are implemented on an as-needed basis. Therefore, not all of them are available. However, new features and specific requests are more than welcome to be discussed. Likewise, the API is highly succeptible to change.
pip install git+https://github.com/MaxHalford/zugzug
What is the probability of having a 1 mana card at the first turn?
We first import zugzug
.
>>> import zugzug as zz
Reproducibility can be enforced by fixing the global random number generator.
>>> import random
>>> random.seed(42)
For this analysis, we're not interested in any card in particular. Instead, we are interested by the mana cost of each cost. We can thus define two kinds of cards, one that costs 1 mana and 1 that costs 2 mana.
>>> one_mana_card = zz.cards.Card(name='1 Mana Card', mana=1)
>>> two_mana_card = zz.cards.Card(name='2 Mana Card', mana=2)
Let us now define a simulation function. One parameter will determine one many 1 mana cards are in the deck, whilst the other will indicate if we're looking for 1 mana cards during the mulligan phase or not. The details of the simulation are influenced by these two parameters. We'll output a boolean value which tells whether or not a 1 mana card is in hand.
>>> def sim(n_ones, mulligan):
...
... # Create a deck with 1 and 2 mana cards
... deck = [one_mana_card] * n_ones + [two_mana_card] * (30 - n_ones)
...
... # Indicate that we want to fish for 1 mana cards during the mulligan phase
... wishlist = [one_mana_card] * 3 if mulligan else []
... game = zz.Game(deck, wishlist=wishlist)
...
... # Go to the first turn
... game.next_turn()
...
... return one_mana_card in game.hand
We may now call the run
function by providing it with the simulation function. We'll also choose how many repetitions we want to do and a set of values for each parameter. The rest is taken care by zugzug
.
>>> results = zz.run(sim, n=1000, n_ones=range(1, 7), mulligan=[False, True])
>>> print(results)
ββββββββββββ€βββββββββββββ€ββββββββββββ€βββββββββ€βββββββββββ
β n_ones β mulligan β median β mean β stdev β
ββββββββββββͺβββββββββββββͺββββββββββββͺβββββββββͺβββββββββββ‘
β 1 β False β 0.0733945 β 0.128 β 0.334257 β
ββββββββββββΌβββββββββββββΌββββββββββββΌβββββββββΌβββββββββββ€
β 1 β True β 0.15445 β 0.236 β 0.424835 β
ββββββββββββΌβββββββββββββΌββββββββββββΌβββββββββΌβββββββββββ€
β 2 β False β 0.171141 β 0.255 β 0.436079 β
ββββββββββββΌβββββββββββββΌββββββββββββΌβββββββββΌβββββββββββ€
β 2 β True β 0.33612 β 0.402 β 0.490547 β
ββββββββββββΌβββββββββββββΌββββββββββββΌβββββββββΌβββββββββββ€
β 3 β False β 0.293651 β 0.37 β 0.483046 β
ββββββββββββΌβββββββββββββΌββββββββββββΌβββββββββΌβββββββββββ€
β 3 β True β 0.595841 β 0.553 β 0.497432 β
ββββββββββββΌβββββββββββββΌββββββββββββΌβββββββββΌβββββββββββ€
β 4 β False β 0.402527 β 0.446 β 0.497324 β
ββββββββββββΌβββββββββββββΌββββββββββββΌβββββββββΌβββββββββββ€
β 4 β True β 0.760355 β 0.676 β 0.468234 β
ββββββββββββΌβββββββββββββΌββββββββββββΌβββββββββΌβββββββββββ€
β 5 β False β 0.589253 β 0.549 β 0.497842 β
ββββββββββββΌβββββββββββββΌββββββββββββΌβββββββββΌβββββββββββ€
β 5 β True β 0.826146 β 0.742 β 0.437753 β
ββββββββββββΌβββββββββββββΌββββββββββββΌβββββββββΌβββββββββββ€
β 6 β False β 0.702552 β 0.627 β 0.483844 β
ββββββββββββΌβββββββββββββΌββββββββββββΌβββββββββΌβββββββββββ€
β 6 β True β 0.87578 β 0.801 β 0.399448 β
ββββββββββββ§βββββββββββββ§ββββββββββββ§βββββββββ§βββββββββββ
How much mana does Frizz Kindleroost save?
Summoning Frizz Kindleroot reduces the mana cost of each dragon in the deck by 2. To measure how much mana this saves on average, we can run a simulation where the turns go by as long as Frizz is not in hand. We can do this by calling game.play_until
with the zz.conditions.Playable(frizz)
condition.
>>> import random
>>> import zugzug as zz
>>> random.seed(42)
>>> dragon = zz.cards.Minion(name='Dragon', mana=None, race=zz.races.DRAGON)
>>> frizz = zz.cards.FrizzKindleroost()
>>> def sim(mulligan, n_dragons):
...
... deck = [frizz] + [dragon] * n_dragons
... deck = deck + [zz.cards.Wisp()] * (30 - len(deck))
... game = zz.Game(deck, wishlist=[frizz] if mulligan else [])
...
... game.play_until(zz.conditions.Playable(frizz))
...
... return sum(2 for card in game.deck if card == dragon)
>>> results = zz.run(sim, n=1000, mulligan=[False, True], n_dragons=[2, 4, 6, 8, 10, 12])
>>> print(results)
ββββββββββββββ€ββββββββββββββ€βββββββββββ€βββββββββ€ββββββββββ
β mulligan β n_dragons β median β mean β stdev β
ββββββββββββββͺββββββββββββββͺβββββββββββͺβββββββββͺββββββββββ‘
β False β 2 β 1.93236 β 1.898 β 1.5761 β
ββββββββββββββΌββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β False β 4 β 3.90851 β 3.7 β 2.6178 β
ββββββββββββββΌββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β False β 6 β 5.98276 β 5.728 β 3.76034 β
ββββββββββββββΌββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β False β 8 β 8.03237 β 7.678 β 4.73653 β
ββββββββββββββΌββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β False β 10 β 10.1261 β 9.72 β 5.68805 β
ββββββββββββββΌββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β False β 12 β 12.0412 β 11.724 β 6.57381 β
ββββββββββββββΌββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β True β 2 β 2.02603 β 2.038 β 1.59408 β
ββββββββββββββΌββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β True β 4 β 4.07219 β 3.948 β 2.79376 β
ββββββββββββββΌββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β True β 6 β 6.28 β 6.12 β 3.74802 β
ββββββββββββββΌββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β True β 8 β 9.52564 β 8.224 β 4.74514 β
ββββββββββββββΌββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β True β 10 β 11.5612 β 10.298 β 5.81214 β
ββββββββββββββΌββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β True β 12 β 13.8684 β 12.224 β 6.94997 β
ββββββββββββββ§ββββββββββββββ§βββββββββββ§βββββββββ§ββββββββββ
How long does it take to get Zixor Prime in hand?
This was the first analysis I did. You can see the code I used in this gist. Using zugzug
reduces the amount of necessary of code and helps a bit with readability. It also helped me gain trust in my analysis by delegating the game mechanics to zugzug
.
This simulation is a bit more verbose than the previous ones because their are two first phases involved. First of all we need to wait that Zixor is in hand. Then, once Zixor's deathrattle has triggered, we have to wait to draw Zixor Prime. The goal of this analysis is to study the impact of draw cards such as Diving Gryphon, Scavenger's Ingenuity, and Tracking.
>>> import random
>>> import zugzug as zz
>>> random.seed(42)
>>> zixor = zz.cards.Zixor()
>>> zixor_prime = zz.cards.ZixorPrime()
>>> gryphon = zz.cards.DivingGryphon()
>>> si = zz.cards.ScavengersIngenuity()
>>> tracking = zz.cards.Tracking(zixor_prime, zixor, si, gryphon)
>>> tracking.wishlist.append(tracking)
>>> wisp = zz.cards.Wisp()
>>> playlist = [si, gryphon, tracking]
>>> wishlist = [zixor, si, gryphon, tracking]
>>> def sim(n_gryphons, n_si, n_tracking):
...
... deck = (
... [zixor] +
... [gryphon] * n_gryphons +
... [si] * n_si +
... [tracking] * n_tracking +
... [wisp] * (30 - 1 - n_gryphons - n_si - n_tracking)
... )
... game = zz.Game(deck, wishlist=wishlist, playlist=playlist)
...
... # Play until Zixor is playable
... game.play_until(zz.conditions.Playable(zixor))
...
... # Assume it takes 0 to 2 turns to get Zixor killed
... for _ in range(random.randint(0, 2)):
... game.next_turn()
...
... # Insert Zixor Prime into the deck once Zixor's deathrattle triggers
... game.play_card(zixor)
...
... # Wait until Zixor Prime is playable
... game.play_until(zz.conditions.Playable(zixor_prime))
...
... return game.turn
>>> results = zz.run(
... sim, n=1000,
... n_gryphons=[0, 1, 2],
... n_si=[0, 1, 2],
... n_tracking=[0, 1, 2]
... )
>>> print(results)
ββββββββββββββββ€βββββββββ€βββββββββββββββ€βββββββββββ€βββββββββ€ββββββββββ
β n_gryphons β n_si β n_tracking β median β mean β stdev β
ββββββββββββββββͺβββββββββͺβββββββββββββββͺβββββββββββͺβββββββββͺββββββββββ‘
β 0 β 0 β 0 β 22.7881 β 21 β 6.45024 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 0 β 0 β 1 β 19.7245 β 18.687 β 5.65376 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 0 β 0 β 2 β 17.959 β 16.835 β 4.97056 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 0 β 1 β 0 β 16.3727 β 16.652 β 6.19462 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 0 β 1 β 1 β 15.2869 β 15.328 β 5.3125 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 0 β 1 β 2 β 13.6 β 13.836 β 4.43517 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 0 β 2 β 0 β 11.4167 β 12.864 β 4.98481 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 0 β 2 β 1 β 10.7182 β 12.204 β 4.35814 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 0 β 2 β 2 β 9.76364 β 11.118 β 3.61623 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 1 β 0 β 0 β 16.5435 β 16.481 β 6.1929 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 1 β 0 β 1 β 15.1866 β 15.239 β 5.34588 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 1 β 0 β 2 β 13.0821 β 13.556 β 4.74694 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 1 β 1 β 0 β 11.4 β 13.165 β 5.41434 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 1 β 1 β 1 β 10.7222 β 12.243 β 4.57109 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 1 β 1 β 2 β 9.48864 β 11.186 β 3.80866 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 1 β 2 β 0 β 8.91096 β 10.991 β 4.01036 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 1 β 2 β 1 β 8.47466 β 10.254 β 3.19683 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 1 β 2 β 2 β 8.35616 β 9.604 β 2.60845 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 2 β 0 β 0 β 11.8519 β 13.515 β 5.63556 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 2 β 0 β 1 β 11.1833 β 12.664 β 4.79897 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 2 β 0 β 2 β 9.51818 β 11.342 β 3.88692 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 2 β 1 β 0 β 9.30172 β 11.208 β 4.12065 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 2 β 1 β 1 β 8.75954 β 10.536 β 3.56559 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 2 β 1 β 2 β 8.47466 β 9.96 β 2.95112 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 2 β 2 β 0 β 8.4311 β 9.948 β 3.02795 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 2 β 2 β 1 β 8.33056 β 9.391 β 2.44256 β
ββββββββββββββββΌβββββββββΌβββββββββββββββΌβββββββββββΌβββββββββΌββββββββββ€
β 2 β 2 β 2 β 8.28616 β 9.138 β 2.1353 β
ββββββββββββββββ§βββββββββ§βββββββββββββββ§βββββββββββ§βββββββββ§ββββββββββ
What is the likelihood of having Scalerider in hand without a dragon?
To do.
I don't expect anyone else to use this code but you never know. Here are the steps you'll want to follow:
$ python3 -m venv .
$ source ./bin/activate
$ pip install -e ".[dev]"
$ python3 setup.py develop
You can run tests with pytest
and mypy
.
Licensed under the WTFPL.