Skip to content

Instantly share code, notes, and snippets.

@ShadowKyogre
Last active May 18, 2017 21:24
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ShadowKyogre/c21563441c1ad47cb32fd2477eae3512 to your computer and use it in GitHub Desktop.
Save ShadowKyogre/c21563441c1ad47cb32fd2477eae3512 to your computer and use it in GitHub Desktop.
DnD 5e Stat Sheet Processor created by ShadowKyogre - https://repl.it/Hxsr/70
from ast import literal_eval
from collections import Counter, OrderedDict
from itertools import chain
import math
import yaml
## Constants
STATS = ('str', 'dex', 'con', 'int', 'wis', 'cha')
PB = {0:8, 1:9, 2:10, 3:11, 4:12, 5:13, 7:14, 9:15}
SKILLS = OrderedDict([
('str',
['Athletics']),
('dex',
['Acrobatics', 'Sleight of hand', 'Stealth']),
('int',
['Arcana','History', 'Investigation', 'Nature', 'Religion']),
('wis',
['Animal Handling', 'Insight', 'Medicine', 'Perception', 'Survival']),
('cha',
['Deception', 'Intimidation', 'Performance'])
])
## Util Funcs
def ordered_load(stream, Loader=yaml.Loader, object_pairs_hook=OrderedDict):
class OrderedLoader(Loader):
pass
def construct_mapping(loader, node):
loader.flatten_mapping(node)
return object_pairs_hook(loader.construct_pairs(node))
OrderedLoader.add_constructor(
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
construct_mapping)
return yaml.load(stream, OrderedLoader)
## Funcs
def proficiency_bonus(level):
return math.ceil(level/1E10) + math.ceil(level/4)
def stat_slash_fmt(stat_map):
return ' / '.join(' '.join((k, str(stat_map[k]))) for k in STATS)
def stat_modifier(stat):
return (stat - 10) // 2
def cantrip_tier(level):
return round((level + 1) / 6 + 0.5)
## Input/Output
### Load sheet data
with open('stat-sheet.yaml', 'r') as f:
sheet_data = ordered_load(f)
### Calculate Proficiency Bonus
clevel = sum(v['Levels'] for v in sheet_data['Classes'].values())
prof_bonus = proficiency_bonus(clevel)
### Verify Point Buy investments
pb_total = sum(sheet_data['Point Buy'].values())
if pb_total < 27:
raise ValueError("Not all stat points are assigned!")
elif pb_total > 27:
raise ValueError("Too many points!")
### Add racials and species overlay
pb_base_stats = Counter({k: PB[v] for k, v in sheet_data['Point Buy'].items()})
base_stats = pb_base_stats.copy()
base_stats.update(sheet_data['Racial Bonuses'])
dict.update(base_stats, sheet_data['Overlays']['Pre-ASI'])
final_stats = base_stats.copy()
### Update stats based on progression stages
progression_lines = [
'=== Progression',
'[options="header"]',
'|===',
(''.join(chain("| ", (' ^| {0}'.format(k) for k in STATS))))
]
for label, progress in sheet_data['Progression'].items():
p_lock = progress.get('Lock', {})
if p_lock:
is_p_locked = (
p_lock['level'] > sheet_data['Classes'].get(p_lock['class'], {}).get('Levels', 0)
)
if is_p_locked:
continue
p_asi = progress.get('ASIs', {})
final_stats.update(p_asi)
progression_lines.append(''.join(
chain('h| {0}'.format(label),
(' ^| {0}'.format(p_asi.get(k, '')) for k in STATS))
))
for feat, feat_data in progress.get('Feats', {}).items():
progression_lines.append('| 6+h| {0}'.format(feat))
if feat_data is not None:
f_asi = feat_data.get('ASIs', {})
progression_lines.append(''.join(
chain('| ',
(' ^| {0}'.format(f_asi.get(k, '')) for k in STATS))
))
final_stats.update(f_asi)
progression_lines.append("|===")
### Update with overlays that should be applied after stats are calculated
dict.update(final_stats, sheet_data['Overlays']['Post-ASI'])
### Create breakdown of base stats
breakdown_lines = [
'=== Base Stats',
'[options="header"]',
'|===',
(''.join(chain("| ", (' ^| {0}'.format(k) for k in STATS)))),
''.join(chain(
'h| Point Buy',
(' | {0}'.format(pb_base_stats.get(k, '')) for k in STATS)
)),
''.join(chain(
'h| Racial',
(' | {0}'.format(sheet_data['Racial Bonuses'].get(k, '')) for k in STATS)
)),
''.join(chain(
'h| Pre-ASI',
(' | {0}'.format(sheet_data['Overlays']['Pre-ASI'].get(k, '')) for k in STATS)
)),
''.join(chain(
'h| Post-ASI',
(' | {0}'.format(sheet_data['Overlays']['Post-ASI'].get(k, '')) for k in STATS)
)),
'|===',
]
### Calculate stat modifiers
fs_modifiers = Counter({k: stat_modifier(v) for k, v in final_stats.items()})
### Calculate saving throw values
saves = fs_modifiers.copy()
saves.update({k: prof_bonus for k in sheet_data['Saves']['Proficiencies']})
global_save_bonus = sheet_data['Saves']['Bonus']['Global']
if isinstance(global_save_bonus, str):
global_save_bonus = literal_eval(global_save_bonus.format(**fs_modifiers))
saves.update({k: global_save_bonus for k in saves.keys()})
for k, v in sheet_data['Saves']['Bonus']['Specific'].items():
if isinstance(v, str):
v = literal_eval(v.format(**fs_modifiers))
saves[k] += v
### Calculate Max HP
if 'HDOverride' in sheet_data.keys():
hd_size = sheet_data['HDOverride']['Size']
hd_extras = sheet_data['HDOverride']['Extras']
hd_avg = hd_size / 2 + 0.5
hp = (hd_avg + fs_modifiers['con']) * hd_extras
hp += sum(
(hd_avg + fs_modifiers['con'] + v.get('HP Bonus', 0)) * v['Levels']
for k, v in sheet_data['Classes'].items()
)
else:
first_value = True
hp = 0
for k, v in sheet_data['Classes'].items():
if first_value:
hp += (
(v['HitDie'] + fs_modifiers['con'])
+ (v['HitDie']/2 + 1 + fs_modifiers['con'])*(v['Levels']-1)
)
first_value = False
else:
hp += (v['HitDie']/2 + 1 + fs_modifiers['con'])*(v['Levels'])
hp += v.get('HP Bonus', 0) * v['Levels']
hp = int(hp)
skills_lines = [
"== Skills",
'[cols="4"]',
"|===",
]
for ability, skills in SKILLS.items():
skills_lines.append("4+h|{0}|".format(ability))
skills_lines.append("h| Proficient h| Expert h| Passive Bonus")
for skill in skills:
skill_data = sheet_data['Skills'].get(skill, {})
skills_lines.append(">h| {0} | {1} | {2} | {3}".format(
skill,
skill_data.get('Proficient', ''),
skill_data.get('Expert', ''),
sheet_data['Skills'].get(skill, {}).get('PBonus', '')
))
skills_lines.append("|===")
print("== Stats")
print("[horizontal]")
print("HP::", hp)
print("AC::",
literal_eval(sheet_data['AC']['Formula'].format(**fs_modifiers)),
'({})'.format(sheet_data['AC']['Notes'])
)
print("Stats::", ' / '.join("{0} {1} ({2})".format(k, final_stats[k], fs_modifiers[k]) for k in STATS))
print("Saving Throws::", ' / '.join(
"{0}{2} {1}".format(
k,
saves[k],
'*' if k in sheet_data['Saves']['Proficiencies'] else ''
)
for k in STATS
))
print("Proficiency Bonus::", prof_bonus)
print("Cantrip Tier::", cantrip_tier(clevel))
print()
print('\n'.join(skills_lines))
print()
print("== Breakdown")
print('\n'.join(breakdown_lines))
print()
print('\n'.join(progression_lines))
Basics:
Name: Alicia Arsami
Gender: Female
Race: Devil's Tongue Tiefling (Peacock)
Background: Entertainer (Veteran)
Apperance:
Biography:
Inventory:
Point Buy: {str: 0, dex: 5, con: 7, int: 1, wis: 7, cha: 7}
Racial Bonuses: {int: 1, cha: 2}
HDOverride:
Size: 10
Extras: 16
Classes:
Warlock:
Subclass: [Undying Light, Tome]
Levels: 18
HitDie: 8
HP Bonus: 2
Hierophant:
Subclass: [Arcana]
Levels: 2
HitDie: 8
HP Bonus: 2
Overlays:
Pre-ASI: {str: 24, dex: 20, con: 24}
Post-ASI: {int: 19}
AC:
Formula: 14 + 2 + 1 + 5 + {dex}
Notes: Natural armor (14+Dex), Shield, Cloak of Protection, Boon of Star Scales
Saves:
Proficiencies: [dex, con, wis, cha]
Bonus:
Global: 1
Specific: {dex: 5}
Skills:
Deception:
Proficient: Warlock
Intimidation:
Proficient: Warlock
Arcana:
Proficient: Arcana Hierophant
Acrobatics:
Proficient: Background
Performance:
Proficient: Background
Perception:
Proficient: Planetar overlay
Stealth:
Proficient: Planetar overlay
Progression:
Initial:
Feats:
Tough:
Actor:
ASIs: {cha: 1}
ASI1:
Lock: {class: Warlock, level: 4}
ASIs: {cha: 2}
Feats:
War Caster:
ASI2:
Lock: {class: Warlock, level: 8}
ASIs: {cha: 2}
Feats:
Spell Sniper:
ASI3:
Lock: {class: Warlock, level: 12}
ASIs: {dex: 2}
Feats:
Rune Knowledge - Naudiz, Sowilo:
ASI4:
Lock: {class: Warlock, level: 16}
ASIs: {wis: 2}
Feats:
Rune Mastery - Sowilo:
Demigod:
ASIs: {cha: 4}
Feats:
Resilient - Dexterity:
ASIs: {dex: 1}
Elemental Adept - Fire:
Star and Shadow Reader - Radiant:
Magic Initiate - Warlock:
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment