Files
dungeon-sheets/dungeonsheets/stats.py
T

215 lines
7.7 KiB
Python

import math
from collections import namedtuple
from .armor import NoArmor, NoShield, HeavyArmor, Shield, Armor
from .weapons import Weapon
from .features import (UnarmoredDefenseMonk, UnarmoredDefenseBarbarian,
DraconicResilience, Defense, FastMovement,
UnarmoredMovement, GiftOfTheDepths, RemarkableAthelete,
SeaSoul, JackOfAllTrades, SoulOfTheForge, QuickDraw,
NaturalExplorerRevised, FeralInstinct, DreadAmbusher,
SuperiorMobility, AmbushMaster, RakishAudacity,
NaturalArmor)
from math import ceil
def findattr(obj, name):
"""Similar to builtin getattr(obj, name) but more forgiving to
whitespace and capitalization.
"""
# Come up with several options
name = name.strip()
# check for +X weapons, armor, shields
bonus = 0
for i in range(1, 11):
if (f'+{i}' in name) or (f'+ {i}' in name):
bonus = i
name = name.replace(f'+{i}', '').replace(f'+ {i}', '')
break
py_name = name.replace('-', '_').replace(' ', '_').replace("'", "")
camel_case = "".join([s.capitalize() for s in py_name.split('_')])
if hasattr(obj, py_name):
# Direct lookup
attr = getattr(obj, py_name)
elif hasattr(obj, camel_case):
# CamelCase lookup
attr = getattr(obj, camel_case)
else:
raise AttributeError(f'{obj} has no attribute {name}')
if bonus > 0:
if issubclass(attr, Weapon) or issubclass(attr, Shield) or issubclass(attr, Armor):
attr = attr.improved_version(bonus)
return attr
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'))
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, character, Character):
self._check_dict(character)
score = character._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'):
is_proficient = (self.ability_name in character.saving_throw_proficiencies)
if is_proficient:
saving_throw += character.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
self.value = val
class Skill():
"""An ability-based skill, such as athletics."""
def __init__(self, ability):
self.ability_name = ability
def __set_name__(self, character, name):
self.skill_name = name.lower().replace('_', ' ')
self.character = character
def __get__(self, character, owner):
ability = getattr(character, self.ability_name)
modifier = ability.modifier
# Check for proficiency
is_proficient = self.skill_name in character.skill_proficiencies
if is_proficient:
modifier += character.proficiency_bonus
elif character.has_feature(JackOfAllTrades):
modifier += character.proficiency_bonus // 2
elif character.has_feature(RemarkableAthelete):
if self.ability_name.lower() in ('strength',
'dexterity', 'constitution'):
modifier += ceil(character.proficienc_bonus / 2.)
# Check for expertise
is_expert = self.skill_name in character.skill_expertise
if is_expert:
modifier += character.proficiency_bonus
return modifier
class ArmorClass():
"""
The Armor Class of a character
"""
def __get__(self, char, Character):
armor = char.armor or NoArmor()
ac = armor.base_armor_class
# calculate and apply modifiers
if armor.dexterity_mod_max is None:
ac += char.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 += shield.base_armor_class
# Compute feature-specific additions
if char.has_feature(UnarmoredDefenseMonk):
if (isinstance(armor, NoArmor) and isinstance(shield, NoShield)):
ac += char.wisdom.modifier
if char.has_feature(UnarmoredDefenseBarbarian):
if isinstance(armor, NoArmor):
ac += char.constitution.modifier
if char.has_feature(DraconicResilience):
if isinstance(armor, NoArmor):
ac += 3
if char.has_feature(Defense):
if not isinstance(armor, NoArmor):
ac += 1
if char.has_feature(SoulOfTheForge):
if isinstance(armor, HeavyArmor):
ac += 1
# Check if any magic items add to AC
for mitem in char.magic_items:
if hasattr(mitem, 'ac_bonus'):
ac += mitem.ac_bonus
return ac
class Speed():
"""
The speed of a character
"""
def __get__(self, char, Character):
speed = char.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):
speed += 10
if char.has_feature(SuperiorMobility):
speed += 10
if isinstance(char.armor, NoArmor) or (char.armor is None):
for f in char.features:
if isinstance(f, UnarmoredMovement):
speed += f.speed_bonus
if char.has_feature(GiftOfTheDepths):
if 'swim' not in other_speed:
other_speed += ' ({:d} swim)'.format(speed)
if char.has_feature(SeaSoul):
if 'swim' not in other_speed:
other_speed += ' (30 swim)'
return '{:d}{:s}'.format(speed, other_speed)
class Initiative():
"""A character's 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
ini = '{:+d}'.format(ini)
has_advantage = (char.has_feature(NaturalExplorerRevised) or
char.has_feature(FeralInstinct) or
char.has_feature(AmbushMaster))
if has_advantage:
ini += '(A)'
return ini