import logging
from copy import deepcopy, copy
from DnD_5e.utility_methods_dnd import NullLogger
[docs]
class Encounter:
"""
This class is used for running encounters
"""
def __init__(self, **kwargs):
"""
Set up the encounter.
:param kwargs: keyword arguments. Valid arguments are as follows:
:param name: what *self* is called. A unique name is recommended but not required
:param teams: a list of :py:class:`Team` s
:param max_rounds: the maximum number of rounds the encounter should last (default 50). To prevent infinite loops.
"""
self._name = kwargs.get("name", "Encounter")
if not isinstance(self._name, str):
raise ValueError("The name of the Encounter must be a string")
self._logger = kwargs.get("logger", logging.getLogger("DnD_5e.encounter"))
self._teams = []
teams = kwargs.get("teams")
if teams:
try:
for the_team in teams:
self.add_team(the_team)
except TypeError: # trying to iterate over a non-iterable
raise ValueError("Must provide a non-empty list (or set or tuple) of Teams")
else:
raise ValueError("Must provide a non-empty list (or set or tuple) of Teams")
self._combatants = []
for the_team in self.get_teams():
self._combatants.extend(the_team.get_combatants())
# according to https://rpg.stackexchange.com/questions/93183/how-many-rounds-does-the-average-combat
# -encounter-last, maximum number of rounds for a typical combat encounter is 5. So I figure if normal combat
# takes more than 50 rounds, something is wrong.
self._max_rounds = kwargs.get("max_rounds", 50)
self._round = 1
self._initial_state = self.get_copy_of_teams_and_combatants()
[docs]
def get_copy_of_teams_and_combatants(self):
"""
Make a copy of the teams (and the combatants within them), so we have a state to reset to
:return:
"""
copied_teams = deepcopy(self.get_teams())
copied_combatants = []
for the_team in copied_teams:
copied_combatants.extend(the_team.get_combatants())
return {
'teams': copied_teams,
'combatants': copied_combatants
}
[docs]
def get_initial_teams(self):
"""
:return: the copy of `self._teams` that is stored in `self._initial_state`
"""
return self._initial_state['teams']
[docs]
def get_initial_combatants(self):
"""
:return: the copy of `self._combatants` that is stored in `self._initial_state`
"""
return self._initial_state['combatants']
def __eq__(self, other) -> bool:
"""
Compare *self* and *other* to determine if they are equal based on the following characteristics:
:py:attr:`hit_dice`, :py:attr:`max_hp`, ability scores, :py:attr:`proficiencies`, :py:attr:`features`,
:py:attr:`fighting_styles`
:param other: the Encounter to be compared
:type other: :py:class:`Encounter`
:return: True if *self* equals *other*, False otherwise
:rtype: bool
"""
if other is self:
return True
if type(other) != type(self): # pylint: disable=unidiomatic-typecheck
return False
if len(self.get_teams()) != len(other.get_teams()):
return False
if self.get_max_rounds() != other.get_max_rounds():
return False
for i, the_team in enumerate(self.get_teams()):
if not the_team == other.get_teams()[i]:
return False
return True
[docs]
def get_name(self) -> str:
"""
:return: name
"""
return self._name
[docs]
def set_name(self, name: str):
self._name = name
[docs]
def get_logger(self) -> logging.Logger:
"""
:return: logger
"""
return self._logger
[docs]
def get_teams(self) -> list:
"""
:return: teams
"""
return self._teams
[docs]
def get_combatants(self) -> list:
"""
:return: list of :py:class:`Combatant` s
"""
return self._combatants
[docs]
def get_current_combatant(self):
"""
:return: the Combatant who should take a turn now
:rtype: :py:class:`Combatant`
"""
combatants = self.get_combatants()
return combatants[self.get_round() % len(combatants)]
[docs]
def get_max_rounds(self) -> int:
"""
:return: maximum number of rounds before aborting this encounter
"""
return self._max_rounds
[docs]
def get_round(self) -> int:
"""
:return: round number
"""
return self._round
[docs]
def get_end_condition(self) -> bool:
"""
Check to see if it is time to end this encounter
:return: False if at least two teams have some alive members, True otherwise
"""
num_conscious_teams = 0
for the_team in self.get_teams():
if the_team.has_any_conscious():
num_conscious_teams += 1
if num_conscious_teams > 1:
return False
return True
[docs]
def get_team_stats(self) -> dict:
"""
Get stats for every Team
:return:
"""
return {t.get_name(): t.get_stats() for t in self.get_teams()}
[docs]
def get_combatant_stats(self) -> dict:
"""
Get stats for every Combatant
:return:
"""
result = {}
for comb in self.get_combatants():
result[comb.get_name()] = {"damage_dealt": comb.get_damage_dealt(), "damage_taken": comb.get_damage_taken(),
"times_gone_unconscious": comb.get_times_unconscious(), "times_end_conscious": 0,
"times_end_unconscious": 0, "times_end_dead": 0, "end_hp": comb.get_current_hp()}
if comb.has_condition("unconscious"):
result[comb.get_name()]["times_end_unconscious"] += 1
elif comb.has_condition("dead"):
result[comb.get_name()]["times_end_dead"] += 1
else:
result[comb.get_name()]["times_end_conscious"] += 1
return result
[docs]
def get_encounter_statnames(self):
"""
Return the stats (besides the ones for Combatant and Team) that this Encounter tracks.
Used by a Simulation to know which stats to keep track of.
TODO: should this be a static method or a class method?
:return:
"""
return ["rounds"]
[docs]
def get_stats(self) -> dict:
"""
Get various stats about how the Encounter went
:return: a dict containing the stats
"""
result = {}
result["team"] = self.get_team_stats()
result["combatants"] = self.get_combatant_stats()
result["rounds"] = self.get_round()
return result
[docs]
def set_logger(self, logger: str):
"""
Set *self._logger* to the logger with the name specified in *logger*
:param logger: the name of the Logger to use, or the logging.Logger object itself
:return: None
"""
try:
self._logger = logging.getLogger(logger)
except TypeError:
if isinstance(logger, (logging.Logger, NullLogger)):
self._logger = logger
else:
self.get_logger().error("Tried to set logger to an invalid value", stack_info=True)
raise ValueError("logger must be a string (the name of a logger), a Logger object, or NullLogger")
[docs]
def add_team(self, the_team):
"""
Add a given Team to the encounter
:param the_team: the :py:class:`Team` to add
:type the_team: :py:class:`Team`
:return:
"""
try:
the_team.has_any_dead()
except AttributeError:
self.get_logger().error("Tried to add a team that is not of class Team", stack_info=True)
raise ValueError("Tried to add a team that is not of class Team")
self._teams.append(the_team)
# TODO: shouldn't I be adding the combatants here??
[docs]
def roll_initiative(self):
self.get_combatants().sort(key=lambda x: (x.roll_initiative(), x.get_dexterity()))
self.get_logger().info("%s initiative order: %s", self.get_name(), str([c.get_name() for c in self.get_combatants()]))
[docs]
def take_turn(self, comb):
"""
The specified :py:class:`Combatant` takes their turn in combat
:param comb: the :py:class:`Combatant` whose turn it is
:type comb: :py:class:`Combatant`
:return:
"""
return comb.take_turn(self.get_teams())
[docs]
def run(self):
"""
Run the encounter
:return:
"""
self.get_logger().info("%s: starting the encounter", self.get_name())
self.roll_initiative()
while True:
self.get_logger().info("Round %d", self.get_round())
unconscious = [comb.get_name() for comb in self.get_combatants() if comb.has_condition("unconscious")]
if unconscious:
self.get_logger().info("Unconscious: %s", str(unconscious))
dead = [comb.get_name() for comb in self.get_combatants() if comb.has_condition("dead")]
if dead:
self.get_logger().info("Dead: %s", str(dead))
for i in range(len(self.get_combatants())):
self.take_turn(self.get_combatants()[i])
if self.get_end_condition():
return 0
self._round += 1
if self.get_round() > self.get_max_rounds():
self.get_logger().warning("Encounter did not finish in maximum number of rounds")
return -1
[docs]
def reset(self):
"""
Reset attributes of *self* and of the Combatants in this Encounter. Do this to prepare to run the Encounter
again.
:return: None
"""
self._round = 1
self._teams = self.get_initial_teams()
self._combatants = self.get_initial_combatants()
self._initial_state = self.get_copy_of_teams_and_combatants()