Files
dungeon-sheets/dungeonsheets/stats.py
T

287 lines
9.1 KiB
Python

import math
from collections import namedtuple
from math import ceil
import logging
from dungeonsheets.armor import HeavyArmor, NoArmor, NoShield
from dungeonsheets.features import (
AmbushMaster,
Defense,
DraconicResilience,
DreadAmbusher,
FastMovement,
FeralInstinct,
GiftOfTheDepths,
JackOfAllTrades,
NaturalArmor,
NaturalExplorerRevised,
QuickDraw,
RakishAudacity,
RemarkableAthlete,
SeaSoul,
SoulOfTheForge,
SuperiorMobility,
UnarmoredDefenseBarbarian,
UnarmoredDefenseMonk,
UnarmoredMovement,
)
log = logging.getLogger(__name__)
def mod_str(modifier):
"""Converts a modifier to a string, eg 2 -> '+2'."""
return "{:+d}".format(modifier)
def ability_mod_str(character, ability):
return mod_str(getattr(character, ability).modifier)
def stat_abbreviation(stat_name):
"""Abbreviate the name of an ability."""
return stat_name.upper()[:3]
AbilityScore = namedtuple("AbilityScore", ("value", "modifier", "saving_throw", "name"))
class Ability:
ability_name = None
def __init__(self, default_value=10):
self.default_value = default_value
def __set_name__(self, character, name):
self.ability_name = name
def _check_dict(self, obj):
if not hasattr(obj, "_ability_scores"):
# No ability score dictionary exists
obj._ability_scores = {self.ability_name: self.default_value}
elif self.ability_name not in obj._ability_scores.keys():
# ability score dictionary exists but doesn't have this ability
obj._ability_scores[self.ability_name] = self.default_value
def __get__(self, actor, Actor):
self._check_dict(actor)
score = actor._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(
actor, "saving_throw_proficiencies"
):
is_proficient = self.ability_name in actor.saving_throw_proficiencies
if is_proficient:
saving_throw += actor.proficiency_bonus
# Check for bonuses to saving throws from magic items
for mitem in actor.magic_items:
saving_throw += mitem.st_bonus(ability=self.ability_name)
# saving_throw += getattr(mitem, "st_bonus", 0)
# Create the named tuple
value = AbilityScore(modifier=modifier, value=score, saving_throw=saving_throw, name=self.ability_name)
return value
def __set__(self, actor, val):
self._check_dict(actor)
actor._ability_scores[self.ability_name] = val
self.value = val
class Skill:
"""An ability-based skill, such as athletics.
Attributes
----------
ability_name:
The name of the ability, as a python-compatible string.
skill_name:
The name of the base ability that determines the skill
modifier.
is_proficient
Bool that describes if the owner is proficient in this skill.
is_expertise
Bool that describes if the owner has expertise in this skill.
is_jack_of_all_trades
Bool that describes if this skill benefits from Jack of All
Trades feature (False if already proficient).
is_remarkable_athlete
Bool that describes if this skill benefits from Remarkable
Athlete feature (False if already proficient).
modifier
The base ability modifier, after relevant proficiency bonuses
have been applied.
proficiency_modifier
The bonus that is applied to the base ability. Usually the same
as the owner's proficiency bonus if ``is_proficient`` is True,
but can be different based on class features.,
"""
ability_name = ""
skill_name = ""
actor = None
def __init__(self, ability):
self.ability_name = ability
def __set_name__(self, owner, name):
self.skill_name = name.lower().replace("_", " ")
def __get__(self, actor, owner):
self.actor = actor
return self
def __str__(self):
return self.skill_name.title()
@property
def is_remarkable_athlete(self):
already_proficient = (self.is_proficient or self.is_expertise)
if self.actor.has_feature(RemarkableAthlete) and not already_proficient:
return True
else:
return False
@property
def is_jack_of_all_trades(self):
already_proficient = (self.is_proficient or self.is_expertise or self.is_remarkable_athlete)
if self.actor.has_feature(JackOfAllTrades) and not already_proficient:
return True
else:
return False
@property
def is_proficient(self):
# Check for proficiency
proficiencies = [p.replace("_", " ") for p in self.actor.skill_proficiencies]
is_proficient = self.skill_name in proficiencies
return is_proficient
@property
def is_expertise(self):
return self.skill_name in self.actor.skill_expertise
@property
def proficiency_modifier(self):
modifier = 0
if self.is_proficient:
modifier += self.actor.proficiency_bonus
if self.is_remarkable_athlete:
modifier += ceil(self.actor.proficiency_bonus / 2.0)
if self.is_jack_of_all_trades:
modifier += self.actor.proficiency_bonus // 2
# Check for expertise
if self.is_expertise:
modifier += self.actor.proficiency_bonus
return modifier
@property
def modifier(self):
ability = getattr(self.actor, self.ability_name)
modifier = ability.modifier + self.proficiency_modifier
log.info("%s modifier for '%s': %d", self, self.actor.name, modifier)
return modifier
class ArmorClass:
"""
The Armor Class of a character
"""
def __get__(self, actor, Actor):
armor = actor.armor or NoArmor()
ac = armor.base_armor_class
# calculate and apply modifiers
if armor.dexterity_mod_max is None:
ac += actor.dexterity.modifier
else:
ac += min(actor.dexterity.modifier, armor.dexterity_mod_max)
if actor.has_feature(NaturalArmor):
ac = max(ac, 13 + actor.dexterity.modifier)
shield = actor.shield or NoShield()
ac += shield.base_armor_class
# Compute feature-specific additions
if actor.has_feature(UnarmoredDefenseMonk):
if isinstance(armor, NoArmor) and isinstance(shield, NoShield):
ac += actor.wisdom.modifier
if actor.has_feature(UnarmoredDefenseBarbarian):
if isinstance(armor, NoArmor):
ac += actor.constitution.modifier
if actor.has_feature(DraconicResilience):
if isinstance(armor, NoArmor):
ac += 3
if actor.has_feature(Defense):
if not isinstance(armor, NoArmor):
ac += 1
if actor.has_feature(SoulOfTheForge):
if isinstance(armor, HeavyArmor):
ac += 1
# Check if any magic items add to AC
for mitem in actor.magic_items:
if hasattr(mitem, "ac_bonus"):
ac += mitem.ac_bonus
return ac
class Speed:
"""
The speed of a character
"""
def __get__(self, actor, Actor):
speed = actor.race.speed
other_speed = ""
if isinstance(speed, str):
other_speed = speed[2:]
speed = int(speed[:2]) # ignore other speeds, like fly
if actor.has_feature(FastMovement):
if not isinstance(actor.armor, HeavyArmor):
speed += 10
if actor.has_feature(SuperiorMobility):
speed += 10
if isinstance(actor.armor, NoArmor) or (actor.armor is None):
for f in actor.features:
if isinstance(f, UnarmoredMovement):
speed += f.speed_bonus
if actor.has_feature(GiftOfTheDepths):
if "swim" not in other_speed:
other_speed += " ({:d} swim)".format(speed)
if actor.has_feature(SeaSoul):
if "swim" not in other_speed:
other_speed += " (30 swim)"
return "{:d}{:s}".format(speed, other_speed)
class NumericalInitiative:
"""A numerical representation of initiative"""
def __get__(self, actor, Actor):
ini = actor.dexterity.modifier
if actor.has_feature(QuickDraw):
ini += actor.proficiency_bonus
if actor.has_feature(DreadAmbusher):
ini += actor.wisdom.modifier
if actor.has_feature(RakishAudacity):
ini += actor.charisma.modifier
has_advantage = (
actor.has_feature(NaturalExplorerRevised)
or actor.has_feature(FeralInstinct)
or actor.has_feature(AmbushMaster)
)
return ini, has_advantage
class Initiative(NumericalInitiative):
"""A character's initiative"""
def __get__(self, actor, Actor):
ini, has_advantage = super(Initiative, self).__get__(actor, Actor)
ini = "{:+d}".format(ini)
if has_advantage:
ini += "(A)"
return ini