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