Remove encounters, refactor Actor into Entity, move more core capabilities into Entity

This commit is contained in:
Matthew DeMartino
2021-05-24 20:38:50 -04:00
parent 601bd0c9aa
commit ddb9d44354
14 changed files with 165 additions and 691 deletions
+7 -16
View File
@@ -23,14 +23,7 @@ from dungeonsheets import (
from dungeonsheets.stats import findattr
from dungeonsheets.weapons import Weapon
from dungeonsheets.readers import read_character_file
from dungeonsheets.encounter.agent import Agent
def read(fname):
return open(os.path.join(os.path.dirname(__file__), fname)).read()
__version__ = read("../VERSION").strip()
from dungeonsheets.entity import Entity
dice_re = re.compile(r"(\d+)d(\d+)")
@@ -146,17 +139,15 @@ def _resolve_mechanic(mechanic, module, SuperClass, warning_message=None):
return Mechanic
class Character(Agent):
class Character(Entity):
"""A generic player character."""
# Character-specific
player_name = ""
dungeonsheets_version = __version__
xp = 0
inspiration = False
attacks_and_spellcasting = ""
class_list = list()
_race = None
_background = None
# Characteristics
@@ -172,7 +163,7 @@ class Character(Agent):
def __init__(
self,
classes: Sequence = [],
class_list: Sequence = [],
levels: Sequence[int] = [],
subclasses: Sequence = [],
**attrs,
@@ -184,7 +175,7 @@ class Character(Agent):
Parameters
==========
classes
class_list
Strings with class names, or character class definitions
representing the characters various D&D classes.
levels
@@ -200,7 +191,7 @@ class Character(Agent):
super(Character, self).__init__()
self.clear()
# make sure class, race, background are set first
my_classes = classes
my_classes = class_list
my_levels = levels
my_subclasses = subclasses
# backwards compatability
@@ -931,7 +922,7 @@ class Character(Agent):
make_sheet(filename, character=self, flatten=kwargs.get("flatten", True))
# Add backwards compatability for tests
# Add backwards compatibility for tests
class Artificer(Character):
def __init__(self, level=1, **attrs):
attrs["classes"] = ["Artificer"]
-9
View File
@@ -1,9 +0,0 @@
class Condition:
"""A condition that can be held by an agent"""
pass
class Blinded(Condition):
pass
class Charmed(Condition):
pass
-1
View File
@@ -1 +0,0 @@
from dungeonsheets.encounter.encounter import Encounter
-45
View File
@@ -1,45 +0,0 @@
from abc import ABC, abstractmethod
from dungeonsheets.encounter.events import Event
class Executable(ABC):
"""Something (like an action) that can be executed.
Executing an action results in an event that is stored
"""
@abstractmethod
def execute(self, subj, obj=None):
"""Execute the given action"""
class Action(Executable):
pass
class BonusAction(Executable):
pass
class Reaction(Executable):
pass
class Movement(Executable):
pass
class LairAction(Executable):
pass
class LegendaryAction(Executable):
pass
class Attack(Action):
def __init__(self, subj, obj):
self.subj = subj
self.obj = obj
-185
View File
@@ -1,185 +0,0 @@
from dungeonsheets.conditions.conditions import Blinded, Charmed
from dungeonsheets.encounter.actions import Attack
from dungeonsheets.stats import Ability, ArmorClass, Initiative, Speed, Skill, \
NumericalInitiative
from abc import ABC
from dungeonsheets.dice import roll
class Agent(ABC):
"""An actor in an encounter. Use Monster or Character, not this class directly!"""
# General attributes
name = ""
alignment = "Neutral"
# Hit points
hp_max = None
# Base stats (ability scores)
strength = Ability()
dexterity = Ability()
constitution = Ability()
intelligence = Ability()
wisdom = Ability()
charisma = Ability()
# Numerical things
armor_class = ArmorClass()
initiative = Initiative()
speed = Speed()
# Proficiencies and Languages
_saving_throw_proficiencies = tuple() # use to overwrite class proficiencies
other_weapon_proficiencies = tuple() # add to class/race proficiencies
skill_proficiencies = list()
skill_expertise = list()
languages = ""
# Skills
acrobatics = Skill(ability="dexterity")
animal_handling = Skill(ability="wisdom")
arcana = Skill(ability="intelligence")
athletics = Skill(ability="strength")
deception = Skill(ability="charisma")
history = Skill(ability="intelligence")
insight = Skill(ability="wisdom")
intimidation = Skill(ability="charisma")
investigation = Skill(ability="intelligence")
medicine = Skill(ability="wisdom")
nature = Skill(ability="intelligence")
perception = Skill(ability="wisdom")
performance = Skill(ability="charisma")
persuasion = Skill(ability="charisma")
religion = Skill(ability="intelligence")
sleight_of_hand = Skill(ability="dexterity")
stealth = Skill(ability="dexterity")
survival = Skill(ability="wisdom")
# Conditions
blinded = Blinded()
charmed = Charmed()
# TODO finish me!
# Inventory
cp = 0
sp = 0
ep = 0
gp = 0
pp = 0
equipment = ""
weapons = list()
magic_items = list()
armor = None
shield = None
# Magic
spellcasting_ability = None
_spells = list()
_spells_prepared = list()
infusions = list()
# Features IN MAJOR DEVELOPMENT
custom_features = list()
feature_choices = list()
# Current Status:
numerical_initiative = NumericalInitiative()
_initiative_roll = False
_current_hp = None
statuses = list()
# TODO: Pull in the monster class-variables here too
def __init__(self):
self.default_actions = list()
self.default_bonus_actions = list()
self.default_reactions = list()
self.default_legendary_actions = list()
self.default_lair_actions = list()
def roll_initiative(self):
init_mod, adv = self.numerical_initiative
val = roll(20)
if adv:
val = max(val, roll(20))
self._initiative_roll = val + init_mod
@property
def current_hp(self):
if self._current_hp is None:
self._current_hp = self.hp_max
return self._current_hp
@current_hp.setter
def current_hp(self, val):
if val < 0:
self._current_hp = 0
else:
self._current_hp = val
@property
def initiative_roll(self):
if self._initiative_roll is False:
self.roll_initiative()
return self._initiative_roll
def make_actions(self, encounter):
"""Return a series of actions"""
# TODO: Dramatically improve logic, consider healing,
# consider encounter state, consider strategy etc.
best_opponent = encounter.opponents(self)[0] # TODO: Choose opponent cleverly
action = self.actions[0](self, best_opponent)
event = action.execute()
encounter.events.append(event)
return [event] # TODO: Also allow bonus actions, etc.
def long_rest(self):
self.current_hp = self.hp_max
# TODO: Support spell slots
self.new_turn()
def new_turn(self):
self._actions = self.default_actions
self._bonus_actions = self.default_bonus_actions
self._reactions = self.default_reactions
self._legendary_actions = self.default_legendary_actions
self._lair_actions = self.default_lair_actions
def has_feature(self, *args, **kwargs):
return False # TODO: Save list of monster features as a list to check
# TODO: Consider having a single list of actions and gain or lose them each
# turn based on interrogating their sub-type instead, using isinstance or
# another method.
@property
def actions(self):
"""All the remaining things I can do in a turn"""
return self._actions
@property
def movement(self):
"""The rest of where I can go in a turn"""
return self._movement
@property
def bonus_actions(self):
"""The rest of the things I can do once in addition to an action"""
return self._bonus_actions
@property
def reactions(self):
"""The remaining things I can do in response to an action"""
return self._reactions
@property
def lair_actions(self):
"""Remaining things I can do at initiative count 20"""
return self._lair_actions
@property
def legendary_actions(self):
"""Remaining things I can do only so many times in a turn after another agent acts"""
return self._legendary_actions
-68
View File
@@ -1,68 +0,0 @@
class Encounter:
"""A combat encounter between two parties -- good guys and bad guys"""
def __init__(self, group_a, group_b):
self.group_a = group_a
self.group_b = group_b
self.all_agents = group_a + group_b
self.events = [] # Should be private?
def opponents(self, agent):
"""Who opposes the given agent in an encounter?"""
if agent in self.group_a:
return self.group_b
else:
return self.group_a
def allies(self, agent):
"""Who sides with the given agent in an encounter?"""
if agent in self.group_a:
return list(set(self.group_a) - set([agent]))
else:
return list(set(self.group_b) - set([agent]))
def reset(self):
self.events = []
self.long_rest()
def simulate(self):
"""Who will win?"""
# Initiative
for agent in self.all_agents:
agent.roll_initiative()
self.all_agents = sorted(self.all_agents, key=lambda a: a.initiative_roll)
# TODO: Support Lair Actions, cleverer loop
while not self.is_encounter_over():
self.new_turn()
for agent in self.all_agents:
agent.make_actions(self)
if self.is_encounter_over():
return self.events
return self.events # Should never get here -- self.is_encounter_over() will end it
def rating(self):
"""Encounter Rating"""
raise NotImplementedError()
def is_encounter_over(self):
"""If all members of one party are at HP <= 0, it's over"""
return (
all([agent.current_hp <= 0 for agent in self.group_a]) or
all([agent.current_hp <= 0 for agent in self.group_b])
)
def long_rest(self):
"""Resets all agents to have full actions, abilities, etc."""
for agent in self.all_agents:
agent.long_rest()
def new_turn(self):
"""Resets turn-based actions for all agents"""
for agent in self.all_agents:
agent.new_turn()
-28
View File
@@ -1,28 +0,0 @@
class Event:
"""An event between one and possibly more entities"""
def __init__(self, action, *args, **kwargs):
self.action = action
self.subj_hp = action.subj.current_hp
class AttackEvent(Event):
"""An attack action completed"""
def __init__(self, action, result, damage, is_hit):
super(AttackEvent, self).__init__(action)
if hasattr(self.action, "obj"):
self.obj_hp = self.action.obj.current_hp
self.result = result
self.damage = damage
self.is_hit = is_hit
def __str__(self):
if self.is_hit:
return f"{self.action.subj.name} Hit! with a {self.result} for {self.damage} damage, leaving {self.action.obj.name} with {self.obj_hp} hitpoints"
else:
return f"{self.action.subj.name} Missed! with a {self.result}. {self.action.obj.name} has {self.obj_hp} hp remaining."
# TODO: Support more events
+87
View File
@@ -0,0 +1,87 @@
from dungeonsheets.stats import Ability, ArmorClass, Initiative, Speed, Skill,
from abc import ABC
import os
def read(fname):
return open(os.path.join(os.path.dirname(__file__), fname)).read()
__version__ = read("../VERSION").strip()
class Entity(ABC):
"""A thing with stats. Use Monster or Character, not this class directly!"""
# General attributes
dungeonsheets_version = __version__
name = ""
alignment = "Neutral"
_race = None
# Hit points
hp_max = None
# Base stats (ability scores)
strength = Ability()
dexterity = Ability()
constitution = Ability()
intelligence = Ability()
wisdom = Ability()
charisma = Ability()
# Numerical things
armor_class = ArmorClass()
initiative = Initiative()
speed = Speed()
# Proficiencies and Languages
_saving_throw_proficiencies = tuple() # use to overwrite class proficiencies
other_weapon_proficiencies = tuple() # add to class/race proficiencies
skill_proficiencies = list()
skill_expertise = list()
languages = ""
senses = ""
# Skills
acrobatics = Skill(ability="dexterity")
animal_handling = Skill(ability="wisdom")
arcana = Skill(ability="intelligence")
athletics = Skill(ability="strength")
deception = Skill(ability="charisma")
history = Skill(ability="intelligence")
insight = Skill(ability="wisdom")
intimidation = Skill(ability="charisma")
investigation = Skill(ability="intelligence")
medicine = Skill(ability="wisdom")
nature = Skill(ability="intelligence")
perception = Skill(ability="wisdom")
performance = Skill(ability="charisma")
persuasion = Skill(ability="charisma")
religion = Skill(ability="intelligence")
sleight_of_hand = Skill(ability="dexterity")
stealth = Skill(ability="dexterity")
survival = Skill(ability="wisdom")
# Inventory
cp = 0
sp = 0
ep = 0
gp = 0
pp = 0
equipment = ""
weapons = list()
magic_items = list()
armor = None
shield = None
# Magic
spellcasting_ability = None
_spells = list()
_spells_prepared = list()
infusions = list()
# Features IN MAJOR DEVELOPMENT
custom_features = list()
feature_choices = list()
def __init__(self):
pass
+3 -13
View File
@@ -3,27 +3,17 @@ shape forms."""
from dungeonsheets.stats import Ability
from dungeonsheets.encounter.agent import Agent
from dungeonsheets.entity import Entity
class Monster(Agent):
class Monster(Entity):
"""A monster that may be encountered when adventuring."""
name = "Generic Monster"
description = ""
challenge_rating = 0
armor_class = 0
skills = "Perception +3, Stealth +4"
senses = ""
languages = ""
strength = Ability()
dexterity = Ability()
constitution = Ability()
intelligence = Ability()
wisdom = Ability()
charisma = Ability()
speed = 30
swim_speed = 0
swim_speed = 0 # TODO: Consider refactoring stats.Speed to consider all of these just like we do stats.Ability
fly_speed = 0
hp_max = 10
hit_dice = "1d6"
+58 -62
View File
@@ -64,10 +64,6 @@ def findattr(obj, name):
def mod_str(modifier):
"""Converts a modifier to a string, eg 2 -> '+2'."""
return "{:+d}".format(modifier)
if modifier == 0:
return str(modifier)
else:
return "{:+}".format(modifier)
AbilityScore = namedtuple("AbilityScore", ("value", "modifier", "saving_throw"))
@@ -90,25 +86,25 @@ class Ability:
# ability score dictionary exists but doesn't have this ability
obj._ability_scores[self.ability_name] = self.default_value
def __get__(self, character, Character):
self._check_dict(character)
score = character._ability_scores[self.ability_name]
def __get__(self, entity, Entity):
self._check_dict(entity)
score = entity._ability_scores[self.ability_name]
modifier = math.floor((score - 10) / 2)
# Check for proficiency
saving_throw = modifier
if self.ability_name is not None and hasattr(
character, "saving_throw_proficiencies"
entity, "saving_throw_proficiencies"
):
is_proficient = self.ability_name in character.saving_throw_proficiencies
is_proficient = self.ability_name in entity.saving_throw_proficiencies
if is_proficient:
saving_throw += character.proficiency_bonus
saving_throw += entity.proficiency_bonus
# Create the named tuple
value = AbilityScore(modifier=modifier, value=score, saving_throw=saving_throw)
return value
def __set__(self, character, val):
self._check_dict(character)
character._ability_scores[self.ability_name] = val
def __set__(self, entity, val):
self._check_dict(entity)
entity._ability_scores[self.ability_name] = val
self.value = val
@@ -118,27 +114,27 @@ class Skill:
def __init__(self, ability):
self.ability_name = ability
def __set_name__(self, character, name):
def __set_name__(self, entity, name):
self.skill_name = name.lower().replace("_", " ")
self.character = character
self.character = entity
def __get__(self, character, owner):
ability = getattr(character, self.ability_name)
def __get__(self, entity, owner):
ability = getattr(entity, self.ability_name)
modifier = ability.modifier
# Check for proficiency
is_proficient = self.skill_name in character.skill_proficiencies
is_proficient = self.skill_name in entity.skill_proficiencies
if is_proficient:
modifier += character.proficiency_bonus
elif character.has_feature(JackOfAllTrades):
modifier += character.proficiency_bonus // 2
elif character.has_feature(RemarkableAthelete):
modifier += entity.proficiency_bonus
elif entity.has_feature(JackOfAllTrades):
modifier += entity.proficiency_bonus // 2
elif entity.has_feature(RemarkableAthelete):
if self.ability_name.lower() in ("strength", "dexterity", "constitution"):
modifier += ceil(character.proficienc_bonus / 2.0)
modifier += ceil(entity.proficienc_bonus / 2.0)
# Check for expertise
is_expert = self.skill_name in character.skill_expertise
is_expert = self.skill_name in entity.skill_expertise
if is_expert:
modifier += character.proficiency_bonus
modifier += entity.proficiency_bonus
return modifier
@@ -147,36 +143,36 @@ class ArmorClass:
The Armor Class of a character
"""
def __get__(self, char, Character):
armor = char.armor or NoArmor()
def __get__(self, entity, Entity):
armor = entity.armor or NoArmor()
ac = armor.base_armor_class
# calculate and apply modifiers
if armor.dexterity_mod_max is None:
ac += char.dexterity.modifier
ac += entity.dexterity.modifier
else:
ac += min(char.dexterity.modifier, armor.dexterity_mod_max)
if char.has_feature(NaturalArmor):
ac = max(ac, 13 + char.dexterity.modifier)
shield = char.shield or NoShield()
ac += min(entity.dexterity.modifier, armor.dexterity_mod_max)
if entity.has_feature(NaturalArmor):
ac = max(ac, 13 + entity.dexterity.modifier)
shield = entity.shield or NoShield()
ac += shield.base_armor_class
# Compute feature-specific additions
if char.has_feature(UnarmoredDefenseMonk):
if entity.has_feature(UnarmoredDefenseMonk):
if isinstance(armor, NoArmor) and isinstance(shield, NoShield):
ac += char.wisdom.modifier
if char.has_feature(UnarmoredDefenseBarbarian):
ac += entity.wisdom.modifier
if entity.has_feature(UnarmoredDefenseBarbarian):
if isinstance(armor, NoArmor):
ac += char.constitution.modifier
if char.has_feature(DraconicResilience):
ac += entity.constitution.modifier
if entity.has_feature(DraconicResilience):
if isinstance(armor, NoArmor):
ac += 3
if char.has_feature(Defense):
if entity.has_feature(Defense):
if not isinstance(armor, NoArmor):
ac += 1
if char.has_feature(SoulOfTheForge):
if entity.has_feature(SoulOfTheForge):
if isinstance(armor, HeavyArmor):
ac += 1
# Check if any magic items add to AC
for mitem in char.magic_items:
for mitem in entity.magic_items:
if hasattr(mitem, "ac_bonus"):
ac += mitem.ac_bonus
return ac
@@ -187,25 +183,25 @@ class Speed:
The speed of a character
"""
def __get__(self, char, Character):
speed = char.race.speed
def __get__(self, entity, Entity):
speed = entity.race.speed
other_speed = ""
if isinstance(speed, str):
other_speed = speed[2:]
speed = int(speed[:2]) # ignore other speeds, like fly
if char.has_feature(FastMovement):
if not isinstance(char.armor, HeavyArmor):
if entity.has_feature(FastMovement):
if not isinstance(entity.armor, HeavyArmor):
speed += 10
if char.has_feature(SuperiorMobility):
if entity.has_feature(SuperiorMobility):
speed += 10
if isinstance(char.armor, NoArmor) or (char.armor is None):
for f in char.features:
if isinstance(entity.armor, NoArmor) or (entity.armor is None):
for f in entity.features:
if isinstance(f, UnarmoredMovement):
speed += f.speed_bonus
if char.has_feature(GiftOfTheDepths):
if entity.has_feature(GiftOfTheDepths):
if "swim" not in other_speed:
other_speed += " ({:d} swim)".format(speed)
if char.has_feature(SeaSoul):
if entity.has_feature(SeaSoul):
if "swim" not in other_speed:
other_speed += " (30 swim)"
return "{:d}{:s}".format(speed, other_speed)
@@ -214,19 +210,19 @@ class Speed:
class NumericalInitiative:
"""A numerical representation of initiative"""
def __get__(self, char, Character):
ini = char.dexterity.modifier
if char.has_feature(QuickDraw):
ini += char.proficiency_bonus
if char.has_feature(DreadAmbusher):
ini += char.wisdom.modifier
if char.has_feature(RakishAudacity):
ini += char.charisma.modifier
def __get__(self, entity, Entity):
ini = entity.dexterity.modifier
if entity.has_feature(QuickDraw):
ini += entity.proficiency_bonus
if entity.has_feature(DreadAmbusher):
ini += entity.wisdom.modifier
if entity.has_feature(RakishAudacity):
ini += entity.charisma.modifier
has_advantage = (
char.has_feature(NaturalExplorerRevised)
or char.has_feature(FeralInstinct)
or char.has_feature(AmbushMaster)
entity.has_feature(NaturalExplorerRevised)
or entity.has_feature(FeralInstinct)
or entity.has_feature(AmbushMaster)
)
return ini, has_advantage
@@ -234,8 +230,8 @@ class NumericalInitiative:
class Initiative(NumericalInitiative):
"""A character's initiative"""
def __get__(self, char, Character):
ini, has_advantage = super(Initiative, self).__get__(char, Character)
def __get__(self, entity, Entity):
ini, has_advantage = super(Initiative, self).__get__(entity, Entity)
ini = "{:+d}".format(ini)
if has_advantage:
ini += "(A)"