def probability_of_save_fail(dices,
                             success,
                             after_crit_wound,
                             roll: Roll,
                             context,
                             rend,
                             crit_wnd=0) -> float:
    if after_crit_wound > success:
        raise ValueError(
            f'trying to compute {after_crit_wound} success after crit amongst {success} successes'
        )
    if after_crit_wound > crit_wnd:
        raise ValueError(
            f'trying to compute {after_crit_wound} success after crit with {crit_wnd} crits'
        )

    regular_pass_rate = binomial(dices - crit_wnd, success - after_crit_wound)
    regular_pass_rate *= pow(roll.fail(context, rend),
                             success - after_crit_wound)
    regular_pass_rate *= pow(roll.success(context, rend),
                             (dices - crit_wnd) - (success - after_crit_wound))

    crit_pass_rate = binomial(crit_wnd, after_crit_wound)
    crit_pass_rate *= pow(
        roll.fail(context, rend + context.get(CRIT_BONUS_REND, 0)),
        after_crit_wound)
    crit_pass_rate *= pow(
        roll.success(context, rend + context.get(CRIT_BONUS_REND, 0)),
        crit_wnd - after_crit_wound)

    return regular_pass_rate * crit_pass_rate
Exemple #2
0
def test_4plus_is_fifty_fifty():
    # given
    roll = Roll(4)
    # assert
    success, crit = roll.chances({})
    assert success + crit == 0.5
    assert -0.000001 < success - 2 / 6 < 0.000001
    assert -0.000001 < crit - 1 / 6 < 0.000001
def probability_of_hit_and_crit(dices, success, crit, roll: Roll,
                                context) -> float:
    success_rate = binomial(dices, success)
    success_rate *= pow(roll.success(context), success) * pow(
        roll.fail(context), dices - success)
    crit_rate = binomial(success, crit) * pow(
        roll.critic_given_success(context), crit) * pow(
            roll.no_critic_given_success(context), success - crit)
    return success_rate * crit_rate
Exemple #4
0
    def __init__(
            self,
            name: str,
            weapons: Union[List[Weapon]],
            move: Union[int, str, Value, Dict[int, Union[int, str, Value]]],
            save: int,
            bravery: int,
            wounds: int,
            min_size: int,
            base: Base,
            rules: List[Rule],
            keywords: List[str],
            cast=0,
            unbind=0,
            named=False,
            max_size=None,
    ):
        self.name = name
        self.weapons = weapons
        self.move = value(move)
        self.save = Roll(save)
        self.extra_save = Roll(7)
        self.bravery = bravery
        self.wounds = wounds
        self.min_size = min_size
        self.size = min_size
        self.max_size = max_size
        if self.max_size is None:
            self.max_size = min_size if 'MONSTER' in keywords or 'HERO' in keywords else min_size
        self.base = base
        self.keywords = keywords
        keywords.append(self.name.upper())
        self.named = named
        self.can_fly = False
        self.run_distance = value('D6')
        self.charge_range = value('2D6')
        self.can_run_and_charge = False
        self.special_users = []
        self.casting_value = value('2D6')
        self.unbinding_value = value('2D6')
        self.morale_roll = value('D6')
        self.notes = []

        self.spells_per_turn = value(cast)
        self.unbind_per_turn = value(unbind)
        self.spells: List[Spell] = [ARCANE_BOLT, MAGIC_SHIELD]
        self.command_abilities: List[CommandAbility] = []

        self.rules = rules
        for r in self.rules:
            r.apply(self)
Exemple #5
0
def test_skinks_stats():
    # given
    context = {
        ENEMY_BASE: infantry_base,
        ENEMY_NUMBERS: 10,
        ENEMY_SAVE: Roll(4),
        SELF_NUMBERS: 1,
    }
    jav = seraphons_by_name['Skinks'].units['Meteoritic Javelin, Star-buckler']
    bolt = seraphons_by_name['Skinks'].units['Boltspitter, Star-buckler']
    bolt_club = seraphons_by_name['Skinks'].units[
        'Boltspitter, Moonstone Club']
    club = seraphons_by_name['Skinks'].units['Moonstone Club, Star-buckler']
    # assert
    assert round(jav.average_damage(context), 2) == 0.03
    assert round(bolt.average_damage(context), 2) == 0.03
    context[SELF_NUMBERS] = 10
    assert round(bolt_club.average_damage(context), 2) == 1.11
    assert round(club.average_damage(context), 2) == 0.83
    context[SELF_NUMBERS] = 20
    context[RANGE] = 5
    assert round(jav.average_damage(context), 2) == 2.5
    assert round(bolt.average_damage(context), 2) == 1.67
    context[SELF_NUMBERS] = 30
    context[RANGE] = 5
    assert round(jav.average_damage(context), 2) == 5
    assert round(bolt.average_damage(context), 2) == 3.33
def probability_of_wound_and_crit(dices,
                                  success,
                                  crit,
                                  roll: Roll,
                                  context,
                                  crit_hit=0) -> float:
    succ_crit_hit = min(crit_hit, success)
    failed_crit_hit = crit_hit - succ_crit_hit
    if context.get(AUTO_WOUND_ON_CRIT,
                   False) and (failed_crit_hit
                               or crit > success - succ_crit_hit):
        # cannot fail, nor get a critical wound roll an automatic success
        return 0
    success_rate = binomial(dices, success)
    # successful after a critical_hit
    if not context.get(AUTO_WOUND_ON_CRIT, False):
        success_rate *= pow(
            roll.success(context, context.get(TOWOUND_MOD_ON_CRIT_HIT, 0)),
            succ_crit_hit)
    # successful without a critical_hit
    success_rate *= pow(roll.success(context), success - succ_crit_hit)
    # failed despite a critical_hit
    success_rate *= pow(
        roll.fail(context, context.get(TOWOUND_MOD_ON_CRIT_HIT, 0)),
        failed_crit_hit)
    # failed without a critical_hit
    success_rate *= pow(roll.fail(context), dices - success - failed_crit_hit)

    # crit rate may be flawed in case of crit_hit bonuses TODO: fix
    crit_rate = binomial(success, crit)
    # critical wound after a critical_hit
    crit_rate *= pow(roll.critic_given_success(context), crit)
    if not (context.get(AUTO_WOUND_ON_CRIT, False) and crit_hit):
        crit_rate *= pow(roll.no_critic_given_success(context), success - crit)
    return success_rate * crit_rate
def test_liberators_stats():
    # given
    context = {
        CHARGING: False,
        ENEMY_BASE: infantry_base,
        ENEMY_NUMBERS: 10,
        ENEMY_SAVE: Roll(4),
        SELF_NUMBERS: 1,
    }
    context2 = {
        CHARGING: False,
        ENEMY_BASE: infantry_base,
        ENEMY_NUMBERS: 10,
        ENEMY_WOUNDS: 5,
        ENEMY_SAVE: Roll(4),
        SELF_NUMBERS: 1,
    }
    shield_libs = stormcasts_by_name['Liberators'].units[
        'Warhammer, Sigmarite shields']
    # assert
    assert round(shield_libs.average_damage(context), 2) == 0.33
    assert round(shield_libs.average_damage(context2), 2) == 0.44
Exemple #8
0
def test_kroxigor_stats():
    # given
    context = {
        ENEMY_BASE: infantry_base,
        ENEMY_NUMBERS: 10,
        ENEMY_SAVE: Roll(4),
        SELF_NUMBERS: 1,
        RANGE: 0.1
    }
    krok = seraphons_by_name['Kroxigors'].units['']
    # assert
    assert round(krok.average_damage(context), 2) == 1.58
    context[RANGE] = 1.5
    assert round(krok.average_damage(context), 2) == 1.33
    def __init__(
        self,
        name: str,
        range_: Union[int, str, Value, Dict[int, Union[int, str, Value]]],
        attacks: Union[int, str, Value, Dict[int, Union[int, str, Value]]],
        tohit,
        towound,
        rend,
        damage: Union[int, str, Value, Dict[int, Union[int, str, Value]]],
        rules: List[Rule],
    ) -> None:
        self.name = name
        self.range = value(range_)
        self.attacks = value(attacks)
        self.tohit = Roll(tohit)
        self.towound = Roll(towound)
        self.rend = value(rend)
        self.damage = value(damage)

        self.attack_rules: List[Callable] = []

        self.rules = rules
        for r in rules:
            r.apply(self)
def test_dryad_stats():
    # given
    context = {
        CHARGING: False,
        ENEMY_BASE: infantry_base,
        ENEMY_NUMBERS: 10,
        ENEMY_SAVE: Roll(4),
        SELF_NUMBERS: 1,
    }
    dryads = sylvaneth_by_name['Dryads'].units['']
    # assert
    assert round(dryads.average_damage(context), 2) == 0.25
    assert round(dryads.average_health(context), 2) == 1.5
    context[SELF_NUMBERS] = 12
    assert round(dryads.average_health(context), 2) == 24
Exemple #11
0
def test_saurus_stats():
    # given
    context = {
        ENEMY_BASE: infantry_base,
        ENEMY_NUMBERS: 10,
        ENEMY_SAVE: Roll(4),
        SELF_NUMBERS: 1,
    }
    clubs = seraphons_by_name['Saurus Warriors'].units['Celestite Club']
    spears = seraphons_by_name['Saurus Warriors'].units['Celestite Spear']
    # assert
    assert round(clubs.average_damage(context), 2) == 0.25
    assert round(spears.average_damage(context), 2) == 0.21
    context[SELF_NUMBERS] = 10
    assert round(clubs.average_damage(context), 2) == 2.5
    assert round(spears.average_damage(context), 2) == 2.08
    context[SELF_NUMBERS] = 20
    assert round(clubs.average_damage(context), 2) == 6.94
    assert round(spears.average_damage(context), 2) == 5.83
    context[SELF_NUMBERS] = 30
    assert round(clubs.average_damage(context), 2) == 17.08
    assert round(spears.average_damage(context), 2) == 13.75
Exemple #12
0
        Rule('Mastery of Magic', mastery_of_magic),
        Rule('Oracle of Eternity', can_reroll_x_dice_during_game(1)),
        Spell('Gift of Change', 8, None),
    ], keywords=[CHAOS, DAEMON, TZEENTCH, WIZARD, HERO, MONSTER, 'LORD OF CHANGE'], cast=2, unbind=2, named=True))


def arcane_tome(u: Unit):
    u.casting_value = u.casting_value + OncePerGame('D6')


sky_sharks = Rule('Sky-sharks', extra_damage_on_keyword(value('D3') - 1, MONSTER))

TZEENTCH_WS.append(Warscroll(
    'Herald of Tzeentch on Burning Chariot', [
        [Weapon('Staff of Change', 2, 1, 4, 3, -1, 'D3', []),
         Weapon('Wake of Fire', 'move across', 1, 7, 7, 0, 0, [Rule('', deal_x_mortal_wound_on_roll('D3', Roll(4)))]),
         Weapon('Screamer`s Lamprey Bites', 1, 6, 4, 3, 0, 1, [sky_sharks])],
        [Weapon('Ritual Dagger', 1, 2, 4, 4, 0, 1, []),
         Weapon('Wake of Fire', 'move across', 1, 7, 7, 0, 0, [Rule('', deal_x_mortal_wound_on_roll('D3', Roll(4)))]),
         Weapon('Screamer`s Lamprey Bites', 1, 6, 4, 3, 0, 1, [sky_sharks])],
    ], 14, 5, 10, 8, 1, monster_base, rules=[
        FLIGHT,
        Rule('Arcane Tome', arcane_tome),
        Spell('Tzeentch`s Firestorm', 9, None),
    ], keywords=[CHAOS, DAEMON, HORROR, TZEENTCH, WIZARD, HERO, 'HERALD ON CHARIOT'], cast=1, unbind=1))


TZEENTCH_WS.append(Warscroll(
    'Herald of Tzeentch on Disc', [
        [Weapon('Magical Flames', 18, 2, 4, 4, 0, 1, []),
         Weapon('Staff of Change', 2, 1, 4, 3, -1, 'D3', []),
class Weapon:
    def __init__(
        self,
        name: str,
        range_: Union[int, str, Value, Dict[int, Union[int, str, Value]]],
        attacks: Union[int, str, Value, Dict[int, Union[int, str, Value]]],
        tohit,
        towound,
        rend,
        damage: Union[int, str, Value, Dict[int, Union[int, str, Value]]],
        rules: List[Rule],
    ) -> None:
        self.name = name
        self.range = value(range_)
        self.attacks = value(attacks)
        self.tohit = Roll(tohit)
        self.towound = Roll(towound)
        self.rend = value(rend)
        self.damage = value(damage)

        self.attack_rules: List[Callable] = []

        self.rules = rules
        for r in rules:
            r.apply(self)

    def average_hits(self,
                     dices,
                     extra_data: dict,
                     mod=0) -> Tuple[float, float]:
        return self.tohit.average(dices, extra_data, mod)

    def average_wounds(self,
                       dices,
                       extra_data: dict,
                       mod=0) -> Tuple[float, float]:
        return self.towound.average(dices, extra_data, mod)

    def unsaved_chances(self, extra_data: dict, extra_rend=0) -> float:
        chances, crit = extra_data[ENEMY_SAVE].chances({},
                                                       mod=self.rend.average(
                                                           extra_data,
                                                           extra_rend))
        return 1 - chances - crit

    def average_damage(self, context: dict, users=1):
        if context.get(RANGE, 0) > self.range.average(context) or \
                                self.range.average(context) > 3 >= context.get(RANGE, 0):
            return 0
        context[WEAPON_RANGE] = self.range.average(context)
        dmg = self.attack_round(copy(context), users)
        return sum([e['damage'] * e['proba'] for e in dmg])

    def attack_round(self, context, users=1):
        my_context = copy(context)
        for r in self.attack_rules:
            r(my_context)

        potential_attacks = {}
        potential_hits = {}
        potential_wounds = {}
        potential_unsaved = {}
        potential_damage = {}
        cleaned_damage = []
        try:
            potential_attacks = [{
                'attacks':
                nb * users,
                'proba':
                proba,
                'mortal_wounds':
                my_context.get(MORTAL_WOUNDS, value(0)) +
                my_context.get(MORTAL_WOUNDS_PER_ATTACK, 0) * nb * users,
            } for (nb, proba) in self.attacks.potential_values(my_context)]
            assert abs(sum([att['proba']
                            for att in potential_attacks]) - 1) <= pow(0.1, 5)
            potential_attacks = cleaned_dict_list(potential_attacks,
                                                  ['attacks', 'mortal_wounds'])

            potential_hits = compute_potential_hits(my_context,
                                                    potential_attacks,
                                                    self.tohit)
            assert abs(sum([hit['proba']
                            for hit in potential_hits]) - 1) <= pow(0.1, 5)
            potential_hits = cleaned_dict_list(
                potential_hits, ['hits', 'crit_hits', 'mortal_wounds'])

            potential_wounds = compute_potential_wounds(
                my_context, potential_hits, self.towound)
            assert abs(sum([wnd['proba']
                            for wnd in potential_wounds]) - 1) <= pow(0.1, 5)
            potential_wounds = cleaned_dict_list(
                potential_wounds, ['wounds', 'crit_wounds', 'mortal_wounds'])

            potential_unsaved = [
                {
                    **wnd, 'unsaved':
                    nb,
                    'unsaved_crit_wound':
                    nb_crit,
                    'proba':
                    wnd['proba'] * probability_of_save_fail(
                        wnd['wounds'],
                        nb,
                        nb_crit,
                        my_context[ENEMY_SAVE],
                        my_context,
                        rend=self.rend.average(my_context),
                        crit_wnd=wnd['crit_wounds'])
                } for wnd in potential_wounds
                for nb in range(wnd['wounds'] + 1) for nb_crit in range(
                    max(0, wnd['crit_wounds'] - (wnd['wounds'] - nb)),
                    min(wnd['crit_wounds'], nb) + 1)
            ]
            assert abs(
                sum([unsvd['proba']
                     for unsvd in potential_unsaved]) - 1) <= pow(0.1, 5)
            potential_unsaved = cleaned_dict_list(
                potential_unsaved,
                ['unsaved', 'unsaved_crit_wound', 'mortal_wounds'])

            potential_damage = compute_potential_damage(
                self.damage, my_context, potential_unsaved)
            assert abs(sum([dmg['proba']
                            for dmg in potential_damage]) - 1) <= pow(0.1, 5)

            potential_full_damage = [{
                **dmg,
                'damage': dmg['damage'] + nb,
                'mortal_wounds': nb,
                'proba': dmg['proba'] * proba,
            } for dmg in potential_damage for (
                nb, proba) in dmg['mortal_wounds'].potential_values(my_context)
                                     ]
            potential_full_damage = cleaned_dict_list(potential_full_damage,
                                                      ['damage'])

            cleaned_damage = [{
                'damage':
                pick * (1 + context.get(MW_ON_DAMAGE, 0)),
                'proba':
                sum([
                    dmg['proba'] for dmg in potential_full_damage
                    if dmg['damage'] == pick
                ])
            } for pick in set(dmg['damage'] for dmg in potential_full_damage)]
            # raise AssertionError  # testing
        except AssertionError:
            info = {
                'potential_attacks': potential_attacks,
                'potential_hits': potential_hits,
                'potential_wounds': potential_wounds,
                'potential_unsaved': potential_unsaved,
                'potential_damage': potential_damage,
                'cleaned_damage': cleaned_damage,
            }
            print(self.name)
            for k, potent in info.items():
                print(f'- {k}:')
                for e in potent:
                    print(
                        str({k: str(v)
                             for k, v in e.items()
                             }).replace("'", "").replace("\"", ""))
                sum_proba = sum(e['proba'] for e in potent)
                print(f'   total={sum_proba}')
            average = sum(
                d.get('damage') * d.get('proba') for d in cleaned_damage)
            print(f'AVERAGE: {average}')

        return cleaned_damage
Exemple #14
0
def test_average_of_3_5plus_is_one():
    # given
    roll = Roll(5)
    # assert
    success, crit = roll.average(3, {})
    assert success + crit == 1
Exemple #15
0
def cloak_of_feathers(u: Unit):
    fly(u)
    u.move = value(14)
    u.save = Roll(4)
Exemple #16
0
 def rule_func(u: Unit):
     u.extra_save = Roll(roll)
Exemple #17
0
class Unit:
    def __init__(
            self,
            name: str,
            weapons: Union[List[Weapon]],
            move: Union[int, str, Value, Dict[int, Union[int, str, Value]]],
            save: int,
            bravery: int,
            wounds: int,
            min_size: int,
            base: Base,
            rules: List[Rule],
            keywords: List[str],
            cast=0,
            unbind=0,
            named=False,
            max_size=None,
    ):
        self.name = name
        self.weapons = weapons
        self.move = value(move)
        self.save = Roll(save)
        self.extra_save = Roll(7)
        self.bravery = bravery
        self.wounds = wounds
        self.min_size = min_size
        self.size = min_size
        self.max_size = max_size
        if self.max_size is None:
            self.max_size = min_size if 'MONSTER' in keywords or 'HERO' in keywords else min_size
        self.base = base
        self.keywords = keywords
        keywords.append(self.name.upper())
        self.named = named
        self.can_fly = False
        self.run_distance = value('D6')
        self.charge_range = value('2D6')
        self.can_run_and_charge = False
        self.special_users = []
        self.casting_value = value('2D6')
        self.unbinding_value = value('2D6')
        self.morale_roll = value('D6')
        self.notes = []

        self.spells_per_turn = value(cast)
        self.unbind_per_turn = value(unbind)
        self.spells: List[Spell] = [ARCANE_BOLT, MAGIC_SHIELD]
        self.command_abilities: List[CommandAbility] = []

        self.rules = rules
        for r in self.rules:
            r.apply(self)

    def formation(self, data: dict, front_size) -> List[int]:
        nb = data.get(SELF_NUMBERS, self.size)
        data[SELF_NUMBERS] = nb
        rows = []
        while nb > 0:
            row = max(min(front_size // self.base.width, nb), 1)
            rows.append(row)
            nb -= row
        return rows

    def describe_formation(self, data: dict, front_size) -> str:
        rows = self.formation(data, front_size)
        if len(rows) == 1 and rows[0] <= 1:
            return ''
        attacking = 0
        _range = data.get(RANGE, 0)
        for row in rows:
            if sum([1 if w.range.average(data) > _range else 0 for w in self.weapons if isinstance(w, Weapon)]):
                attacking += row
            else:
                break
            _range += self.base.depth / INCH
        if len(rows) > 1:
            return f'({rows[0]}x{len(rows)}, {attacking} attacking)'
        return f'({attacking} attacking)'

    def average_damage(self, data: dict, front_size=1000):
        total = 0
        unit_data = copy(data)
        unit_data[SELF_BASE] = self.base
        unit_data[SELF_MOVE] = self.move.average(data)
        _range = data.get(RANGE, 0)
        data[RANGE] = _range
        for row in self.formation(unit_data, front_size):
            # specials
            specials = 0
            if total == 0:
                for sp_usr in self.special_users:
                    total += sp_usr.size * sum([w.average_damage(copy(unit_data)) for w in sp_usr.weapons])
                    specials += sp_usr.size

            users = row - specials
            if users <= 5:
                total += sum([w.average_damage(copy(unit_data), users=users) for w in self.weapons])
            else:  # let's avoid taking years to compute
                total += users * sum([w.average_damage(copy(unit_data)) for w in self.weapons])
            data[RANGE] += self.base.depth / INCH
        data[RANGE] = _range
        return total

    def average_health(self, context: dict):
        nb = context.get(SELF_NUMBERS, self.size)
        rend = context.get(REND, 0)
        save, crit = self.save.chances(context, mod=rend)
        save += crit
        wounds = min(self.wounds, context.get(SELF_WOUNDS, self.wounds))
        life = nb * wounds / (1 - save)
        life /= self.extra_save.fail(context)
        return life

    def average_speed(self, context: dict):
        average_move = self.move.average(context)
        average_sprint = self.run_distance.average(context) + average_move
        average_charge = self.charge_range.average(context) + average_move
        if self.can_run_and_charge:
            average_charge += self.run_distance.average(context)
        return average_move, average_sprint, average_charge

    def speed_grade(self, context: dict):
        m, s, c = self.average_speed(context)
        return round((m + s + c) / 3) - 3

    def speed_description(self, context: dict):
        flight = 'F' if self.can_fly else ''
        m, s, c = self.average_speed(context)
        return f'{int(round(m))}-{int(round(s))}-{int(round(c))}{flight}'

    def magic_power(self, context: dict):
        if self.spells_per_turn == 0:
            return 0
        potential_cast = self.casting_value.potential_values(context)
        spells = []
        for sp in self.spells:
            chances = sum(proba for val, proba in potential_cast if val >= sp.power)
            spells.append(chances * sp.power)
        return self.spells_per_turn.average(context) * sum(spells) / len(spells)

    def unbind_power(self, context: dict):
        if self.unbind_per_turn == 0:
            return 0
        potential_unbind = self.unbinding_value.potential_values(context)
        spells = []
        for sp_power in [5, 6, 7, 8, 9]:
            chances = sum(proba for val, proba in potential_unbind if val >= sp_power)
            spells.append(chances * sp_power)
        return self.unbind_per_turn.average(context) * sum(spells) / len(spells)

    def morale_grade(self, context: dict):
        if HERO in self.keywords or MONSTER in self.keywords:
            return self.bravery
        mod = value('D6').average(context) - self.morale_roll.average(context)
        return self.bravery + mod