def test_pool(): """ Take a bucket of DRVs, and consider the results irrespective of order. """ pool = pools.pool(d6, count=2) assert p(pool == pools.PlainResult(1, 1)) == Fraction(1, 36) assert p(pool == pools.PlainResult(1, 2)) == Fraction(2, 36)
def test_difficulty(diff): """ Setting a difficulty means that some matching sets don't count. """ pool = ore.matches(2, difficulty=diff) assert p(pool.apply(len) > 0) == Fraction(11 - diff, 100) # You wouldn't normally allow_highest and then require a matching set, but # since the probability is the same, we may as well check it. pool = ore.matches(2, difficulty=diff, allow_highest=True) def success(matches): return matches[0].width > 1 assert p(pool.apply(success)) == Fraction(11 - diff, 100) # If you rolled at least one die over the difficulty, but still failed, # then the difficulty actually makes no difference to your distribution. def failure(matches): return not success(matches) and matches[0].height >= diff assert pool.given(failure).is_same( ore.matches(2, allow_highest=True).given(failure)) # That leaves cases where all dice were less than the difficulty. The # probability and distribution of that is an easy one to check. def woeful(matches): return not success(matches) and matches[0].height < diff assert p(pool.apply(woeful)) == Fraction(diff - 1, 10)**2 if diff > 1: assert pool.given(woeful).apply( lambda matches: matches[0].height).is_same( # The maximum of uniformly-distributed values below diff. Pool(d(diff - 1), count=2).apply(max))
def test_pool_examples(): """ Check the examples of using pool() do vaguely work. """ # First example of penalty die def penalty(result): return PlainResult(*(tuple(result)[1:])) penalised = ore.pool(1).apply(penalty).apply(ore.Match.get_matches) assert penalised.is_same(DRV({(): 1})) penalised = ore.pool(2).apply(penalty).apply( ore.Match.get_matches_or_highest) assert penalised.apply(len).is_same(DRV({1: 1})) assert penalised.apply(lambda x: x[0]).is_same( keep_lowest(1, d10, count=2).apply(lambda x: ore.Match(1, next(iter(x))))) # Second example of penalty die def penalty(result): matches = sorted( ore.Match.get_all_sets(result), key=lambda x: (x.width, x.height), ) matches[0] = ore.Match(matches[0].width - 1, matches[0].height) return PlainResult(*(die for match in matches for die in [match.height] * match.width)) penalised = ore.pool(1).apply(penalty).apply(ore.Match.get_matches) assert penalised.is_same(DRV({(): 1})) penalised = ore.pool(3).apply(penalty).apply(ore.Match.get_matches) # Discarding from the narrowest match of 3 dice doesn't affect your chance # of success! Width 3 becomes width 2, and width 2 means there's an # unmatched third die to discard. assert p(penalised.apply(len) > 0) == p(ore.matches(3).apply(len) > 0)
def test_pool_addition(): """ You can add a constant, DRV or pool to a pool, and the effect is of including one or more extra dice in the pool. """ pool = pools.pool(d6) assert p(pool + 1 == pools.PlainResult(1, 1)) == Fraction(1, 6) assert p(pool + d6 == pools.PlainResult(1, 1)) == Fraction(1, 36) assert p(pool + pool == pools.PlainResult(1, 1)) == Fraction(1, 36)
def test_mixed_pool(): """ Not all dice in pool need to be the same, and you can build up a pool one item at a time if you want to. """ pool = pools.pool(d6, d8) assert p(pool == pools.PlainResult(1, 1)) == Fraction(1, 48) assert p(pool == pools.PlainResult(1, 2)) == Fraction(2, 48) assert p(pool == pools.PlainResult(6, 7)) == Fraction(1, 48) assert pool.is_same(pools.pool(d6) + d8)
def test_keep_lowest(): """ Roll N, keep the worst K of some DRV. """ pool = pools.keep_lowest(2, d6, count=3) assert p(pool == pools.PlainResult(6, 6)) == Fraction(1, 216) # There are three ways each to get 1, 1, x for x = 2..6, plus 1, 1, 1. assert p(pool == pools.PlainResult(1, 1)) == Fraction(16, 216) pool0 = pools.keep_lowest(0, d6, count=10) assert pool0.is_same(DRV({pools.PlainResult(): 1}))
def test_given(): """ Conditional probability distribution, given that some predicate is true. """ var = DRV({x: 0.125 for x in range(8)}) var_odd = var.given(lambda x: x % 2 != 0) var_even = var.given(lambda x: x % 2 == 0) assert p(var_odd == 2) == 0 assert p(var_odd == 1) == 0.25 assert p(var_even == 2) == 0.25 assert p(var_even == 1) == 0 var_square = var.given(lambda x: int(sqrt(x))**2 == x) assert p(var_square == 0) == pytest.approx(1 / 3) with pytest.raises(ZeroDivisionError): var.given(lambda x: x == 8)
def test_belle_curve(): """ There's a helpful table in the Godlike rulebook giving the probability of at least one match. This is based on the number of possible permutations of unmatched numbers that can show up. There's only so many ways that N values in the range 1-10 can all be different from each other. """ assert p(ore.matches(1).apply(len) > 0) == 0 assert p(ore.matches(2).apply(len) > 0) == Fraction(1, 10) assert p(ore.matches(3).apply(len) > 0) == Fraction(28, 100) # The table is approximate after this def combinations(d: int) -> int: # This is the combinatorics bit. # 10 choose d for the different sets of values, times d! for the orders # you could roll them in. return math.factorial(10) // math.factorial(10 - d) result = p(ore.matches(4).apply(len) > 0) assert 0.495 <= result < 0.505 assert 1 - result == Fraction(combinations(4), 10**4) if SIZE_LIMIT >= 5: result = p(ore.matches(5).apply(len) > 0) assert 0.695 <= result < 0.705 assert 1 - result == Fraction(combinations(5), 10**5) if SIZE_LIMIT >= 6: result = p(ore.matches(6).apply(len) > 0) assert 0.845 <= result < 0.855 assert 1 - result == Fraction(combinations(6), 10**6) if SIZE_LIMIT >= 7: # Book got this one wrong: it says 93% but it's 93.952% result = p(ore.matches(7).apply(len) > 0) assert 0.93 <= result < 0.94 assert 1 - result == Fraction(combinations(7), 10**7) if SIZE_LIMIT >= 8: result = p(ore.matches(8).apply(len) > 0) assert 0.975 <= result < 0.985 assert 1 - result == Fraction(combinations(8), 10**8) if SIZE_LIMIT >= 9: result = p(ore.matches(9).apply(len) > 0) assert 0.9955 <= result < 0.9965 assert 1 - result == Fraction(combinations(9), 10**9) if SIZE_LIMIT >= 10: # Book rounded this one wrong too, it's actually 99.963712% result = p(ore.matches(10).apply(len) > 0) assert 0.9985 <= result assert 1 - result == Fraction(combinations(10), 10**10)
def test_unstoppable_explosion(): """ An exploded 6 counts as unstoppable. """ assert p( ote.pool(1, explode=True, unstoppable=True).apply(lambda x: x.unstoppable)) == Fraction( 1, 6)
def test_wiggle(): """ Wiggle dice have the potential to be difficult to implement. The documentation lays out what's available to handle them. """ # First example of 'wd' param. drv = ore.matches(3, wd=lambda x: PlainResult(max(x), max(x))) assert drv.is_same( Pool(d10, count=3).apply(lambda x: x + PlainResult(max(x), max(x))).apply( ore.Match.get_matches)) assert p(drv.apply(lambda x: ore.Match(5, 10) in x)) == Fraction(1, 1000) # Second example of 'wd' param drv = ore.matches(3, wd=lambda x, y=PlainResult(10, 9): y) assert drv.is_same( Pool(d10, count=3).apply(lambda x: x + PlainResult(10, 9)).apply( ore.Match.get_matches)) assert p(drv.apply(lambda x: ore.Match(4, 10) in x)) == Fraction(1, 1000)
def test_p(): """ The p function returns the probability that a boolean DRV is True. """ coins = (10 @ DRV({0: 0.5, 1: 0.5})) assert drv.p(coins <= 0) == 0.5**10 assert drv.p(coins >= 10) == 0.5**10 assert drv.p(coins >= 5) > 0.5 assert drv.p(coins >= 5) + drv.p(coins < 5) == 1 # Non-boolean input is rejected, even though 0 == False and 1 == True with pytest.raises(TypeError): drv.p(coins) # It still works when True (or False) is missing. assert drv.p(DRV({False: 1})) == 0 assert drv.p(DRV({True: 1})) == 1
def test_keep_highest(): """ Roll N, keep the best K of some DRV. """ pool = pools.keep_highest(2, d6, count=3) # There are three ways each to get 6, 6, x for x = 1..5, plus 6, 6, 6. assert p(pool == pools.PlainResult(6, 6)) == Fraction(16, 216) assert p(pool == pools.PlainResult(1, 1)) == Fraction(1, 216) # count=1000 acts as a performance test: if the implementation tries to # compute all possibilities and then restrict to 0 dice, it will fail. pool0 = pools.keep_highest(0, d6, count=1000) assert pool0.is_same(DRV({pools.PlainResult(): 1})) # Examples from docs poolA = pools.keep_highest(2, d6) + d6 + d6 poolB = pools.pool(d6, count=3) poolC = pools.keep_highest(2, d6, count=3) poolD = pools.pool(d6, result_type=pools.KeepHighest(2)) + d6 + d6 assert poolA.is_same(poolB) assert not poolA.is_same(poolC) assert poolD.is_same(poolC)
def test_revised_botch(): """ The botch rule is different in Revised. Now if you roll at least one success, you cannot botch, even if it's cancelled out by 1s. """ # With one or two dice, Revised makes no difference from 1st/2nd. die = storyteller.revised_standard(6).apply(storyteller.total) probs = { -1: 0.1, 0: 0.4, 1: 0.5, } assert die.is_close(DRV(probs)) roll2 = (2 @ storyteller.revised_standard(6)).apply(storyteller.total) # With 2 dice, the only rolls that botch are 1+1 .. 1+5 and 2+1 .. 5+1 assert p(roll2 < 0) == pytest.approx(0.09) # It's three dice where the difference kicks in: the case of 2 botches and # one success has probability 0.1 * 0.1 * 0.5 * 3 = 0.015. In Revised # that's not a botch. old_roll = 3 @ storyteller.standard(6) new_roll = (3 @ storyteller.revised_standard(6)).apply(storyteller.total) assert p(old_roll == -1) == pytest.approx(p(new_roll == -1) + 0.015)
def test_unstoppable(dice): """ unstoppable() can be applied to a pool to tell you both the total and the existence (or not) of 6s. """ drv = ote.pool(dice).apply(ote.Unstoppable) drv2 = ote.pool(dice, unstoppable=True) drv3 = drv2.apply(ote.Unstoppable) assert drv.is_same(drv2) assert drv.is_same(drv3) assert drv.apply(lambda x: x.total).is_same(ote.total(dice)) # Check that you can apply(sum) regardless of options. assert drv.apply(lambda x: x.total).is_same(drv.apply(sum)) assert p(drv.apply(lambda x: not x.unstoppable)) == Fraction(5, 6)**dice
def _check( result, values, botch=Fraction(1, 6), single=True, totals=None, highest=None, ): if totals is None: totals = values if highest is None: highest = totals assert result.apply(type).is_same(DRV({opend6.Result: 1})) assert result.apply(int).is_same(values) # Test the fields of Result assert result.apply(lambda x: x.total).is_same(totals) assert result.apply(lambda x: x.highest).is_same(highest) assert p(result.apply(lambda x: x.botch)) == botch assert result.apply(lambda x: x.singleton).is_same(DRV({single: 1}))
def test_botch_cancels(): """Optionally you can switch off the botch-cancels rule.""" result = opend6.total(2, botch_cancels=False) def botch(x): return x.botch def nobotch(x): return not x.botch assert result.apply(lambda x: x.total).is_same(d6 + d6.explode()) assert p(result.apply(botch)) == Fraction(1, 6) assert result.given(botch).apply(lambda x: x.total).is_same(d6 + 1) assert result.given(nobotch).apply(lambda x: x.total).is_same( d6 + d6.explode().given(lambda x: x != 1)) # We're happy with the result of total(), so we can use it to check a few # results of test() for target in range(0, 20, 5): success = opend6.test(2, target, botch_cancels=False) assert success.apply(lambda x: x.success).is_same( result.apply(lambda x: x.total >= target))
def test_hard(): """ Hard dice can be added to the pool. """ # With d+hd, there's only one possible way to get a match assert ore.matches(d=1, hd=1).is_close( DRV({ (ore.Match(2, 10), ): 1 / 10, (): 9 / 10, })) # 2d + hd is still small enough to figure out easily... assert ore.matches(d=2, hd=1).is_close( DRV({ (ore.Match(3, 10), ): 1 / 100, (ore.Match(2, 10), ): 18 / 100, **{(ore.Match(2, n), ): 1 / 100 for n in range(1, 10)}, (): 72 / 100, })) # 3d + hd: now we can get two pairs of 10 and something else. result = p(ore.matches(d=3, hd=1).apply(len) == 2) assert result == pytest.approx(9 * 3 / 1000)
def test_explode(): """ The "Blowing the top" rule doesn't require any special result values, it just adds an exploding d6 sometimes. """ # Probabilities for explosion on 2 dice, with bonus/penalty normal_prob = Fraction(1, 36) bonus_prob = Fraction(1 + 3 * 5, 216) pen_prob = Fraction(1, 216) def low(result): return result < 12 def high(result): return result >= 12 assert ote.total(2, explode=True).given(low).is_same((2 @ d6).given(low)) assert ote.total(2, explode=True).given(high).is_same(d6.explode() + 12, ) assert ote.total(2, explode=5).given(low).is_same((2 @ d6).given(low)) assert ote.total(2, explode=5).given(high).is_same( d6.explode(rerolls=5) + 12, ) assert p(ote.total(2, bonus=0, explode=True) > 12) == normal_prob assert p(ote.total(2, bonus=1, explode=True) > 12) == bonus_prob assert p(ote.total(2, bonus=-1, explode=True) > 12) == pen_prob def short(result): return len(result.values) == 2 def long(result): return len(result.values) == 3 assert ote.pool(2, explode=True).given(short).is_same( Pool(d6, d6).given(lambda x: x != PlainResult(6, 6))) assert ote.pool(2, explode=True).given(long).is_same( (d6.explode().apply(lambda x: PlainResult(6, 6, x))), ) assert ote.pool(2, explode=5).given(short).is_same( Pool(d6, d6).given(lambda x: x != PlainResult(6, 6))) assert ote.pool(2, explode=5).given(long).is_same( (d6.explode(rerolls=5).apply(lambda x: PlainResult(6, 6, x))), ) assert p(ote.pool(2, bonus=0, explode=True).apply(sum) > 12) == normal_prob assert p(ote.pool(2, bonus=1, explode=True).apply(sum) > 12) == bonus_prob assert p(ote.pool(2, bonus=-1, explode=True).apply(sum) > 12) == pen_prob
def test_botch(): """ total() reports a botch as -1. pool() reports a single -1. """ # Probabilities for botch on 2 dice, with bonus/penalty normal_prob = Fraction(1, 36) bonus_prob = Fraction(1, 216) pen_prob = Fraction(1 + 3 * 5, 216) assert ote.total(2, botch=True).is_same( (2 @ d6).apply(lambda x: -1 if x == 2 else x)) assert p(ote.total(2, bonus=0, botch=True) == -1) == normal_prob assert p(ote.total(2, bonus=1, botch=True) == -1) == bonus_prob assert p(ote.total(2, bonus=-1, botch=True) == -1) == pen_prob botch = PlainResult(-1) assert ote.pool(2, botch=True).is_same( Pool(d6, d6).apply(lambda x: botch if x == PlainResult(1, 1) else x)) assert p(ote.pool(2, bonus=0, botch=True) == botch) == normal_prob assert p(ote.pool(2, bonus=1, botch=True) == botch) == bonus_prob assert p(ote.pool(2, bonus=-1, botch=True) == botch) == pen_prob