Source code for DnD_5e.tactics

import random
from copy import copy, deepcopy
from math import inf

[docs] class Tactic: """ A base class for classes that choose an item based on an algorithm stored in method *run_tactic* """ def __init__(self, **kwargs): """ Validate the input and set the instance variables :param kwargs: keyword arguments. Valid arguments are as follows: :param name: what *self* is called :type name: str :param tiebreakers: the Tactics to be applied in case of a tie, in the order in which they will be applied :type tiebreakers: list of :py:class:`Tactic` s :param verbose: if this argument is provided with anything that evaluates to True, :py:attr:`_verbose` is set to True. This means that output about what *self* is doing (and what is happening to *self*) will be printed to the console :raise: ValueError if input is invalid """ self._name = kwargs.get("name", type(self).__name__) if not isinstance(self._name, str): raise ValueError("Name must be a string") self._tiebreakers = kwargs.get("tiebreakers", []) if not isinstance(self._tiebreakers, list): raise ValueError("Tiebreakers must be a list") for tiebreaker in self._tiebreakers: if self._tiebreakers.count(tiebreaker) > 1: raise ValueError("Tiebreakers cannot contain duplicate items") for item in self._tiebreakers: if not isinstance(item, Tactic): raise ValueError("All tiebreakers must be Tactics") verbose = kwargs.get("verbose") self._verbose = bool(verbose) def __eq__(self, other) -> bool: """ Check equality, based on type (each different kind of :py:class:`Tactic` will be a different class and thus a different type) and tiebreakers. .. Warning:: Python uses this method when you call remove on a list or set, so be careful about calling remove. :param other: the other :py:class:`Tactic` to be compared :type other: :py:class:`Tactic` :return: True if *self* equals *other*, False otherwise :rtype: bool """ return type(other) == type(self) and self.get_tiebreakers() == other.get_tiebreakers() # pylint: disable=unidiomatic-typecheck
[docs] def get_name(self) -> str: """ :return: name :rtype: str """ return self._name
[docs] def get_tiebreakers(self) -> list: """ :return: tiebreakers :rtype: list of :py:class:`Tactic` s """ return self._tiebreakers
[docs] def get_verbose(self) -> bool: """ :return: verbose setting :rtype: bool """ return self._verbose
[docs] def append_tiebreaker(self, tiebreaker): """ Add the given tiebreaker to the end of the tiebreakers list :param tiebreaker: the tiebreaker to add :type tiebreaker: Tactic :return: None :raise: ValueError if *tiebreaker* is not a :py:class:`Tactic` or *self* already has *tiebreaker* as a tiebreaker """ if not isinstance(tiebreaker, Tactic): raise ValueError("tiebreaker must be a Tactic") if tiebreaker in self.get_tiebreakers(): raise ValueError(f"${self._name} already has this tactic: ${tiebreaker.get_name()}") self._tiebreakers.append(tiebreaker)
[docs] def extend_tiebreakers(self, tact): """ Add the tiebreakers of the given Tactic to the end of the tiebreakers list :param tiebreaker: the tiebreaker to add :type tiebreaker: Tactic :return: None :raise: ValueError if *tiebreaker* is not a :py:class:`Tactic` or *self* already has *tiebreaker* as a tiebreaker """ if not isinstance(tact, Tactic): raise ValueError("tact must be a Tactic") for tiebreaker in tact.get_tiebreakers(): try: self.append_tiebreaker(tiebreaker) except ValueError: pass # don't copy duplicate tiebreakers, but don't freak out about it
[docs] def run_tactic(self, choices: list, **kwargs) -> list: # pylint: disable=unused-argument """ Use the class's algorithm to select an option from a list. This is the default implementation and it chooses an option at random. This method should be overridden in subclassses. :param choices: the options to choose from :type choices: list :return: a list of the chosen items. List may be empty, it may be identical to *choices*, or it may contain some (but not all) of the options in *choices*. """ if not isinstance(choices, list): raise ValueError("Choices must be a list") choice = random.choice(choices) return [choice]
[docs] def make_choice(self, choices: list, **kwargs): # returns whatever type choices contains """ Return exactly one option from *choices*. The process is this: Try :py:meth:`run_tactic`. If only one option is returned, you are done. Else, try running tiebreakers (in order) until you arrive at one option. If you still don't have exactly one option, choose a random option from whatever is left. :param choices: the options to choose from :type choices: list :param **kwargs: keyword arguments to be passed on to :py:meth:`run_tactic` :return: the chosen element """ if not isinstance(choices, list): raise ValueError("Choices must be a list") if self.get_verbose(): print(f"Running ${self.get_name()} to choose from", choices) new_choices = self.run_tactic(choices, **kwargs) if self.get_verbose(): print(f"${self.get_name()} chose", new_choices) if len(new_choices) == 1: return new_choices[0] if len(new_choices) == 0: # pylint: disable=len-as-condition new_choices = choices tie_choices = new_choices for tiebreaker in self.get_tiebreakers(): if self.get_verbose(): print(f"{self.get_name()} is running {tiebreaker.get_name()} as a tiebreaker") previous_choices = tie_choices tie_choices = tiebreaker.run_tactic(tie_choices, **kwargs, use_tiebreakers=False) if self.get_verbose(): print(f"Tiebreaker {tiebreaker.get_name()} chose {[item.get_name() for item in tie_choices]}") if len(tie_choices) == 1: return tie_choices[0] if len(tie_choices) == 0: # pylint: disable=len-as-condition tie_choices = previous_choices if len(tie_choices) == 1: return tie_choices[0] return random.choice(tie_choices) # need a final clause that guarantees exactly one result is returned
[docs] class ThresholdTactic(Tactic): """ A tactic that has a threshold of some kind (e.g., ac higher than a given number) """ def __init__(self, **kwargs): """ Validate the input and set the instance variables :param kwargs: keyword arguments. Same as in superclass, plus the following: :param threshold: threshold to be used (e.g., consider targets with ac below the threshold) :type threshold: int """ super().__init__(**kwargs) self._threshold = kwargs.get("threshold") if self._threshold is None: self._threshold = self.calculate_threshold(**kwargs) # pylint: disable=assignment-from-no-return self.validate_threshold() def __eq__(self, other) -> bool: """ :param other: :return: """ return super().__eq__(other) and self.get_threshold() == other.get_threshold()
[docs] def calculate_threshold(self, **kwargs) -> int: # pylint: disable=unused-argument """ Calculate threshold using keyword arguments. :param kwargs: passed on from constructor :return: threshold """ raise ValueError("Threshold must be an integer")
[docs] def validate_threshold(self): """ :return: :raise: ValueError if threshold is invalid """ if not isinstance(self._threshold, int) or self._threshold < 0: raise ValueError("Must provide non-negative integer for threshold")
[docs] def get_threshold(self) -> int: """ :return: threshold """ return self._threshold
[docs] class ConditionTactic(Tactic): """ Tactics that select items that meet a certain condition """ def __init__(self, **kwargs): super().__init__(**kwargs)
[docs] def check_condition(self, item, **kwargs) -> bool: # pylint: disable=unused-argument """ Check this tactic's condition. To be implemented in subclasses. :param item: the option that the condition is checked on :return: indication of whether the condition is met or not """ return True
[docs] def run_tactic(self, choices: list, **kwargs) -> list: """ Select the item(s) that meet the given condition :param choices: the options to choose from :type choices: list :return: the options that meet the condition """ if not isinstance(choices, list): raise ValueError("Choices must be a list") result = [] for item in choices: if self.check_condition(item, **kwargs): result.append(item) return result
[docs] class MinTactic(Tactic): """ Select the item(s) that have the lowest value in the given field """ def __init__(self, **kwargs): super().__init__(**kwargs)
[docs] def get_value(self, item, **kwargs): # pylint: disable=unused-argument """ Get the value for whatever detail of *item* we are concerned about :param item: the option we are looking at :return: the numerical value of whatever detail we are concerned about """ return 0
[docs] def run_tactic(self, choices: list, **kwargs) -> list: """ Select the item(s) that have the lowest value in the given field :param choices: the options to choose from :return: the options that have the lowest value in the given field """ if not isinstance(choices, list): raise ValueError("Choices must be a list") lowest = inf # infinity result = [] for item in choices: try: val = self.get_value(item, **kwargs) except AttributeError: raise ValueError("Items in choices must be correct type") if val < lowest: lowest = val result = [item] elif val == lowest: result.append(item) return result
[docs] class MaxTactic(Tactic): """ Select the item(s) that have the highest value in the given field """ def __init__(self, **kwargs): super().__init__(**kwargs)
[docs] def get_value(self, item, **kwargs): # pylint: disable=unused-argument """ Get the value for whatever detail of *item* we are concerned about :param item: the option we are looking at :return: the numerical value of whatever detail we are concerned about """ return 0
[docs] def run_tactic(self, choices: list, **kwargs) -> list: """ Select the item(s) that have the highest value in the given field :param choices: the options to choose from :return: the options that have the highest value in the given field """ if not isinstance(choices, list): raise ValueError("Choices must be a list") highest = -inf # negative infinity result = [] for item in choices: try: val = self.get_value(item, **kwargs) except AttributeError: raise ValueError("Items in choices must be correct type") if val > highest: highest = val result = [item] elif val == highest: result.append(item) return result