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
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
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 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
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
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
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
def test_average_of_3_5plus_is_one(): # given roll = Roll(5) # assert success, crit = roll.average(3, {}) assert success + crit == 1
def cloak_of_feathers(u: Unit): fly(u) u.move = value(14) u.save = Roll(4)
def rule_func(u: Unit): u.extra_save = Roll(roll)
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