diff options
author | Your Name <you@example.com> | 2021-06-12 15:32:53 -0400 |
---|---|---|
committer | Your Name <you@example.com> | 2021-06-12 15:32:53 -0400 |
commit | 01293baa64fa905c5763020bd6c0b4903d41fc78 (patch) | |
tree | 4c49a63852fd84ead388a8fd092d64d2df7f9e1b | |
parent | b27700a7e0b281ece3dea23060c17e0cae28715d (diff) | |
download | dmtool-01293baa64fa905c5763020bd6c0b4903d41fc78.tar.gz dmtool-01293baa64fa905c5763020bd6c0b4903d41fc78.tar.bz2 dmtool-01293baa64fa905c5763020bd6c0b4903d41fc78.zip |
Verified some creature attacks
-rw-r--r-- | Makefile | 2 | ||||
-rwxr-xr-x | configure | 2 | ||||
m--------- | parser/5thSRD | 0 | ||||
-rw-r--r-- | parser/attacks.py | 188 | ||||
-rwxr-xr-x | parser/scrapeToJson.py | 156 | ||||
-rw-r--r--[-rwxr-xr-x] | parser/utils.py | 22 | ||||
-rw-r--r-- | parser/verified.txt | 261 | ||||
-rw-r--r-- | src/cmd_fsops.cc | 10 | ||||
-rw-r--r-- | src/creature.cc | 28 | ||||
-rw-r--r-- | src/item.cc | 8 | ||||
-rw-r--r-- | src/utils.h | 18 | ||||
-rw-r--r-- | src/weapon.cc | 66 |
12 files changed, 591 insertions, 170 deletions
@@ -12,7 +12,7 @@ endif all: $(SOURCES) $(EXECUTABLE) -parsed: parser/scrapeToJson.py parser/utils.py +parsed: parser/scrapeToJson.py parser/utils.py parser/verified.txt python parser/scrapeToJson.py touch parsed/ @@ -17,7 +17,7 @@ LDFLAGS= SOURCE_DIR="src" EXTRAS=" -parsed: parser/scrapeToJson.py parser/utils.py +parsed: parser/scrapeToJson.py parser/utils.py parser/verified.txt python parser/scrapeToJson.py touch parsed/ " diff --git a/parser/5thSRD b/parser/5thSRD -Subproject 5356ad7fc35f76891c64cd36159cc42a04c9c71 +Subproject 5b635b54e81f82396cb96d6bca3c97f296f1c64 diff --git a/parser/attacks.py b/parser/attacks.py new file mode 100644 index 0000000..743a149 --- /dev/null +++ b/parser/attacks.py @@ -0,0 +1,188 @@ +import re +import utils + +with open('parser/verified.txt') as f: + verified = f.read().split('\n') + +# action formatted {'entry': entry, 'name': name, 'text': text, 'type': type} +def procAttackAction(desc, action, weapons): + assert re.match('.*Attack:', action['text']) + action['type'] = 'attack' + details = {} + details['range'] = [0, 0] + details['reach'] = 0 + for rangereach in ['range', 'reach']: + #rangeMatch = re.search('{} (\d+(?:/\d+)?) ft'.format(rangereach), action['text']) + rangeMatch = re.search('{}d? (\S+ ft)'.format(rangereach), action['text']) + if rangeMatch: + distance = rangeMatch.group(1) + if '/' in distance and rangereach == 'range': + distance = [int(part.split('ft')[0].strip()) for part in distance.split('/')] + else: + distance = int(distance.split('ft')[0].strip()) + if rangereach == 'range': + distance = [distance, distance] + details[rangereach] = distance + details['properties'] = [] + toHit = int(re.search('\+(\d+) to hit', action['text']).group(1)) + toHitOverride=None + dmgBonusOverride=None + abilityOverride=None + if details['reach'] > 0 and toHit != desc['prof'] + desc.getBonus('str'): + # Maybe it's dex? + if desc.getBonus('dex') > desc.getBonus('str') and toHit == desc['prof'] + desc.getBonus('dex'): + #print(f'Added finesse to {desc["name"]} attack {action["name"]}.') + details['properties'].append('finesse') + else: + toHitOverride = toHit + if details['reach'] == 0 and details['range'][0] == 0: + toHitOverride = toHit + elif details['reach'] == 0 and toHit != desc['prof'] + desc.getBonus('dex'): + # Maybe it's str? + if toHit == desc['prof'] + desc.getBonus('str'): + abilityOverride = 'str' + if 'spell' in action['text'].split(':')[0].lower(): + # See if it's int, wis, or cha + for a in ['int', 'wis', 'cha']: + if toHit == desc['prof'] + desc.getBonus(a): + abilityOverride = a + break + targetQuals = re.search('(?<=ft).*?(?=_Hit)', action['text']).group(0).split('ft')[-1] + targetQuals = targetQuals.replace('.', '') + if targetQuals.startswith(','): + targetQuals = targetQuals[1:] + targetQuals = targetQuals.strip() + if targetQuals and targetQuals != 'one target' and targetQuals != 'one creature': + details['properties'].append(targetQuals) + details['damage'] = [] + # It could be something like "1 piecring damage" (see sprite). + dmgSection = re.search('_Hit:_ .*?\.', action['text']).group(0) + if 'DC' in dmgSection: + dmgSection = dmgSection.split('DC')[0] + dmgMatch = 'foobar' # Something that won't match + first = True + for dmgMatch in re.findall('(?:plus |or )?\d+(?: \(\d+d\d+[\+− \d]*\))? [a-z]* damage', dmgSection): + if first: + first = False + try: + bonus = dmgMatch.split('(')[1].split(')')[0].split(' ')[-1] + if 'd' in bonus: + bonus = 0 + bonus = int(bonus) + if bonus != 0 and dmgMatch.split('(')[1].split(')')[0].split(' ')[-2] != '+': + bonus *= -1 + if not (bonus == desc.getBonus('str') or (('finesse' in details['properties'] or (details['reach'] == 0 and details['range'][0] > 0)) and bonus == max(desc.getBonus('str'), desc.getBonus('dex')))): + dmgBonusOverride = bonus + except: + pass #print(f'Exception calculating damage bonus for {desc["name"]} attack {action["name"]}') + isOr = 'or ' in dmgMatch + if re.match('\d+ [a-z]* damage', dmgMatch): + details['damage'].append({ + 'dmg_die_count': int(dmgMatch.split(' ')[0]), + 'dmg_die_sides': int(dmgMatch.split(' ')[0]), + 'dmg_type': re.search('[a-z]+(?= damage)', dmgMatch).group(0), + 'is_or': isOr # Always false + }) + else: + toAppend = { + 'dmg_die_count': int(re.search('\d+(?=d\d)', dmgMatch).group(0)), + 'dmg_die_sides': int(re.search('(?<=\dd)\d+', dmgMatch).group(0)), + 'dmg_type': re.search('[a-z]+(?= damage)', dmgMatch).group(0), + 'is_or': isOr + } + if isOr and toAppend['dmg_type'] == details['damage'][-1]['dmg_type'] and toAppend['dmg_die_sides'] == details['damage'][-1]['dmg_die_sides'] + 2: + details['properties'].append('versatile') + else: + details['damage'].append(toAppend) + if abilityOverride: + print(f'Overriding ability for {desc["name"]} ranged weapon {action["name"]}.') + if toHitOverride: + print(f'Overriding toHit bonus for {desc["name"]} weapon {action["name"]}.') + if dmgBonusOverride: + print(f'Overriding damage bonus for {desc["name"]} attack {action["name"]} to {bonus}') + # Text is what we get when we split on the final dmgMatch + texts = action['text'].split(dmgMatch)[1:] + if len(texts) == 0: + texts = [re.search('(?s)(?<=_Hit:_ ).*', action['text']).group(0).strip()] + elif len(texts) > 1: + print(f'ERROR processing wepon text for {desc["name"]}, attack {action["name"]}') + details['text'] = texts[0].strip() + # We may need to move some parts of the name elsewhere + if '(' in action['name']: + details['properties'].append('('.join(action['name'].split('(')[1:])[:-1]) + #details['text'] = '(' + '('.join(action['name'].split('(')[1:]) + " " + details['text'] + action['name'] = action['name'].split('(')[0].strip() + if action['name'][-1] == '.' or action['name'][-1] == ':': + action['name'] = action['name'][:-1].strip() + action['attack'] = {} + for name, value in utils.formatWeapon(action['name'], details['range'][0], details['range'][1], details['reach'], details['damage'], details['text'], properties=details['properties'], toHitOverride=toHitOverride, dmgBonusOverride=dmgBonusOverride, abilityOverride=abilityOverride).items(): + action['attack'][name] = value + # Differentiate between spell attacks and weapons attacks + if 'spell' in action['text'].split(':')[0].lower(): + action['attack']['type'] = 'spell attack' + if desc['name'].lower() in verified: + action['text'] = '' + # Some weapons are non-standard, even if the name matches something "known". + if (len(details['damage']) > 1 and not details['damage'][1]['is_or']) or (len(details['damage']) >= 1 and details['damage'][0]['dmg_die_count'] != (2 if action['name'] == 'greatsword' or action['name'] == 'maul' else 1)) or (len(details['damage']) >= 1 and action['name'] != 'blowgun' and details['damage'][0]['dmg_die_sides'] == 1): + action['attack']['weapon_type'] = 'unknown' + if action['attack']['weapon_type'] != 'unknown': + desc['inventory'].append({'entry': 'item', 'name': action['attack']['name'], 'type': 'weapons', 'text': details['text']}) + +# action formatted {'entry': entry, 'name': name, 'text': text, 'type': type} +def procSpellAction(desc, action, spells): + assert 'spellcasting' in action['name'] + action['type'] = 'spells' + #print('{} has spellcasting!'.format(desc['name'])) + abilities = ['Intelligence', 'Wisdom', 'Charisma'] + for ability in abilities: + if ability in action['text']: + if 'spellcasting_ability' in action: + print('Uh oh, both {} and {} present!'.format(action['spellcasting_ability'], ability)) + exit(1) + action['spellcasting_ability'] = ability.lower()[:3] + if 'spellcasting_ability' not in action: + print('Uh oh, no spellcasting ability!') + exit(1) + # Interpretation of slots differs if is innate or not + action['innate'] = 'innate' in action['text'] + # Now, break down by level + action['levels'] = [] + def getSpells(text): + names = [] + [names.extend(m.split(', ')) for m in re.findall('(?<=\*\*_).*?(?=_\*\*)', text)] + #ret = [] + for name in names: + found = False + for spell in spells: + if spell['name'] == name: + #ret.append(spell) + found = True + break + if not found: + print('Could not find spell: {}!!!!!!!!!!!!'.format(name)) + #return ret + return names + # Sometimes it is in the name (as is case with mephits) + if '/day' in action['name']: + slots = int(re.search('(?<=\()\d+', action['name']).group(0)) + spellNames = getSpells(action['text']) + action['levels'].append({'slots': slots, 'spells': spellNames}) + else: + # Repair pit fiend + m = re.match('(?sm)^(.+) \d+/day each: ', action['text']) + if m: + action['text'] = re.sub(re.escape(m.group(0)), m.group(0) + '**_', action['text']) + action['text'] = re.sub(re.escape(m.group(1)), m.group(1) + '_**\n', action['text']) + for line in action['text'].split('\n')[1:]: + line = line.lower() + if all(string not in line for string in ['(', '/day', 'at will']): + continue + #print('Searching line {}'.format(line)) + # If it's "at will", then slots = 0 + if 'at will' in line: + slots = 0 + else: + slots = int(re.search('(\d+)( slot|/day)', line).group(1)) + spellNames = getSpells(line) + action['levels'].append({'slots': slots, 'spells': spellNames}) + action['name'] = 'spellcasting' diff --git a/parser/scrapeToJson.py b/parser/scrapeToJson.py index b1534c2..5eacb5a 100755 --- a/parser/scrapeToJson.py +++ b/parser/scrapeToJson.py @@ -1,12 +1,17 @@ #!/usr/bin/env python3 - import json import re import utils +import attacks + +class Creature(dict): + def getBonus(self, ability): + return (self['stats'][ability] - 10) // 2 def processMonster(data, weapons, armors, spells): names2names = {'ac': 'Armor Class', 'hp': 'Hit Points', 'speed': 'Speed', 'saves': 'Saving Throws', 'd_resistances': 'Damage Resistances?', 'd_vulnerabilities': 'Damage Vulnerabilities', 'd_immunities': 'Damage Immunities', 'c_immunities': 'Condition Immunities', 'senses': 'Senses', 'langs': 'Languages', 'skills': 'Skills'} - desc = {'entry': 'creatures'} + desc = Creature() + desc['entry'] = 'creatures' for name in names2names: m = re.search('(\*\*{}\.?\*\*)(.*)'.format(names2names[name]), data) if m: @@ -45,8 +50,6 @@ def processMonster(data, weapons, armors, spells): desc['size'] = description.split(' ')[0] desc['alignment'] = description.split(', ')[-1] desc['stats'] = {ability: int(score.strip().split(' ')[0]) for ability, score in zip(['str', 'dex', 'con', 'int', 'wis', 'cha'], re.findall('(?<=\|) *\d.*?(?=\|)', data))} - def getBonus(ability): - return (desc['stats'][ability] - 10) // 2 desc['inventory'] = [] # Fill with weapons and armor desc['observant'] = False # maybe set to true later # Add a few null-valued items that will be set when the creature is first generated @@ -91,15 +94,15 @@ def processMonster(data, weapons, armors, spells): #else: # print('Found {} armor {} (bonus = {})'.format(typ, name, bonus)) if typ == 'light': - armorBonus = bonus + getBonus('dex') + armorBonus = bonus + desc.getBonus('dex') elif typ == 'medium': - armorBonus = bonus + min(2, getBonus('dex')) + armorBonus = bonus + min(2, desc.getBonus('dex')) elif typ == 'heavy': armorBonus = bonus elif typ == 'misc' or typ == 'shield': armorBonus += bonus if armorBonus == 0 and not natural: # Got through all that and came up dry - armorBonus = 10 + getBonus('dex') + armorBonus = 10 + desc.getBonus('dex') if natural: desc['natural_armor'] = {'name': natural, 'bonus': correctAC - armorBonus} elif armorBonus != correctAC: @@ -111,6 +114,12 @@ def processMonster(data, weapons, armors, spells): description = re.search('(?s)(?<={}).*?(?=###|$)'.format('### Description'), data) if description: desc['text'] = description.group(0).strip() + else: + # Try looking for the last entry (**something**) with an empty line and then one or more paragraphs before end of file + description = re.search('(?s)(?<=\*\*).*\n\n(.*?)(?=###|$)', data) + if description: + #print(f'Found description without a header for {desc["name"]}') + desc['text'] = description.group(1).strip() # Next do sections names2sectHeads = {'feature': '\*\*Challenge\*\*', 'action': '### Actions', 'legendary_action': '### Legendary Actions', 'reaction': '### Reactions'} @@ -123,7 +132,7 @@ def processMonster(data, weapons, armors, spells): #text = re.match('(?s)(\s*\w[^\*].*?)([\r\n]+[\*#]|$)', '\n'.join(section.group(0).split('\n')[1:])) #if text and re.search('\w', text.group(1)): # desc[name]['_text'] = text.group(1).strip() - for m in re.findall('(?s)\n\*\*(.*?)\.?\*\*(.*?)(?=\n\*\*|$)', section.group(0)): + for m in re.findall('(?s)\n\*\*(.*?)\.?\*\*(.*?)(?=\n\*\*|\n\n|$)', section.group(0)): desc['features'].append({'entry': 'feature', 'name': m[0].lower(), 'text': m[1].strip(), 'type': name}) # Next, simplify and codify a few things # Guess the proficiency bonus @@ -134,148 +143,31 @@ def processMonster(data, weapons, armors, spells): if skillStr: for skill in skillStr.split(','): skillName, skillBonus, ability = utils.procSkill(skill) - abilityBonus = getBonus(ability) + abilityBonus = desc.getBonus(ability) profTimes = (skillBonus - abilityBonus) / desc['prof'] if round(profTimes) != profTimes: - print('Things came out funny for {}; skill {} has bonus {}, but proficiency is {} and the relevant ability ({}) gets {}'.format(desc['name'], skillName, skillBonus, desc['prof'], ability, getBonus(ability))) + print('Things came out funny for {}; skill {} has bonus {}, but proficiency is {} and the relevant ability ({}) gets {}'.format(desc['name'], skillName, skillBonus, desc['prof'], ability, desc.getBonus(ability))) desc['skills'][skillName] = round(profTimes) savesStr = desc['saves'] desc['saves'] = [] if savesStr: for save in savesStr.split(', '): ability = save.split(' ')[0].lower() - if int(save.split('+')[1]) != getBonus(ability) + desc['prof']: - print('Things came out funny for {}; {} save has bonus {}, but proficiency is {} and the relevant ability ({}) gets {}'.format(desc['name'], ability, int(save.split('+')[1]), desc['prof'], ability, getBonus(ability))) + if int(save.split('+')[1]) != desc.getBonus(ability) + desc['prof']: + print('Things came out funny for {}; {} save has bonus {}, but proficiency is {} and the relevant ability ({}) gets {}'.format(desc['name'], ability, int(save.split('+')[1]), desc['prof'], ability, desc.getBonus(ability))) desc['saves'].append(ability) for action in desc['features']: if re.match('.*Attack:', action['text']): - action['type'] = 'attack' - #toHit = int(re.search('\+(\d+) to hit', action['text']).group(1)) - #selectedAbility = None - #for ability in ['str', 'dex', 'int', 'wis', 'cha', 'con']: - # if desc['prof'] + getBonus(ability) == toHit: - # selectedAbility = ability - # break - #if not selectedAbility: - # print('Cannot find relevant ability for {}, proficiency = {}'.format(desc['name'], desc['prof'])) - # continue - #action['details']['ability'] = selectedAbility - details = {} - details['range'] = [0, 0] - details['reach'] = 0 - for rangereach in ['range', 'reach']: - #rangeMatch = re.search('{} (\d+(?:/\d+)?) ft'.format(rangereach), action['text']) - rangeMatch = re.search('{} ([^,]*),'.format(rangereach), action['text']) - if rangeMatch: - distance = rangeMatch.group(1) - if '/' in distance and rangereach == 'range': - distance = [int(part.split('ft')[0].strip()) for part in distance.split('/')] - else: - distance = int(distance.split('ft')[0].strip()) - if rangereach == 'range': - distance = [distance, distance] - details[rangereach] = distance - details['properties'] = [] - details['damage'] = [] - # It could be something like "1 piecring damage" (see sprite). - dmgSection = re.search('_Hit:_ .*?\.', action['text']).group(0) - for dmgMatch in re.findall('(?:plus |or )?\d+(?: \(\d+d\d+[\+− \d]*\))? [a-z]* damage', dmgSection): - isOr = dmgMatch.split(' ')[0] == 'or' - if re.match('\d+ [a-z]* damage', dmgMatch): - details['damage'].append({ - 'dmg_die_count': int(dmgMatch.split(' ')[0]), - 'dmg_die_sides': int(dmgMatch.split(' ')[0]), - 'dmg_type': re.search('[a-z]+(?= damage)', dmgMatch).group(0), - 'is_or': isOr # Always false - }) - else: - toAppend = { - 'dmg_die_count': int(re.search('\d+(?=d\d)', dmgMatch).group(0)), - 'dmg_die_sides': int(re.search('(?<=\dd)\d+', dmgMatch).group(0)), - 'dmg_type': re.search('[a-z]+(?= damage)', dmgMatch).group(0), - 'is_or': isOr - } - if isOr and toAppend['dmg_type'] == details['damage'][-1]['dmg_type'] and toAppend['dmg_die_sides'] == details['damage'][-1]['dmg_die_sides'] + 2: - details['properties'].append('versatile') - else: - details['damage'].append(toAppend) - details['text'] = re.search('(?s)(_Hit:_ (?:\d+ [^\.]*\.)?)(.*)', action['text']).group(2).strip() - # We may need to move some parts of the name to the text - if '(' in action['name']: - details['text'] = '(' + '('.join(action['name'].split('(')[1:]) + " " + details['text'] - action['name'] = action['name'].split('(')[0].strip() - if action['name'][-1] == '.' or action['name'][-1] == ':': - action['name'] = action['name'][:-1].strip() - action['attack'] = {} - for name, value in utils.formatWeapon(action['name'], details['range'][0], details['range'][1], details['reach'], details['damage'], details['text']).items(): - action['attack'][name] = value - if action['attack']['weapon_type'] != 'unknown': - #desc['inventory'].append(action['attack']) - desc['inventory'].append({'entry': 'item', 'name': action['attack']['name'], 'type': 'weapons', 'text': action['text']}) + attacks.procAttackAction(desc, action, weapons) elif 'spellcasting' in action['name']: - action['type'] = 'spells' - #print('{} has spellcasting!'.format(desc['name'])) - abilities = ['Intelligence', 'Wisdom', 'Charisma'] - for ability in abilities: - if ability in action['text']: - if 'spellcasting_ability' in action: - print('Uh oh, both {} and {} present!'.format(action['spellcasting_ability'], ability)) - exit(1) - action['spellcasting_ability'] = ability.lower()[:3] - if 'spellcasting_ability' not in action: - print('Uh oh, no spellcasting ability!') - exit(1) - # Interpretation of slots differs if is innate or not - action['innate'] = 'innate' in action['text'] - # Now, break down by level - action['levels'] = [] - def getSpells(text): - names = [] - [names.extend(m.split(', ')) for m in re.findall('(?<=\*\*_).*?(?=_\*\*)', text)] - #ret = [] - for name in names: - found = False - for spell in spells: - if spell['name'] == name: - #ret.append(spell) - found = True - break - if not found: - print('Could not find spell: {}!!!!!!!!!!!!'.format(name)) - #return ret - return names - # Sometimes it is in the name (as is case with mephits) - if '/day' in action['name']: - slots = int(re.search('(?<=\()\d+', action['name']).group(0)) - spellNames = getSpells(action['text']) - action['levels'].append({'slots': slots, 'spells': spellNames}) - else: - # Repair pit fiend - m = re.match('(?sm)^(.+) \d+/day each: ', action['text']) - if m: - action['text'] = re.sub(re.escape(m.group(0)), m.group(0) + '**_', action['text']) - action['text'] = re.sub(re.escape(m.group(1)), m.group(1) + '_**\n', action['text']) - for line in action['text'].split('\n')[1:]: - line = line.lower() - if all(string not in line for string in ['(', '/day', 'at will']): - continue - #print('Searching line {}'.format(line)) - # If it's "at will", then slots = 0 - if 'at will' in line: - slots = 0 - else: - slots = int(re.search('(\d+)( slot|/day)', line).group(1)) - spellNames = getSpells(line) - action['levels'].append({'slots': slots, 'spells': spellNames}) - - action['name'] = 'spellcasting' + attacks.procSpellAction(desc, action, spells) # Remove weapon actions from features (they were just added to inventory) desc['features'] = [a for a in desc['features'] if 'attack' not in a or a['attack']['weapon_type'] == 'unknown'] # Get rid of precalculated passive perception # It's always the last item in senses passivePercep = int(desc['senses'].split(' ')[-1]) - shouldBe = 10 + getBonus('wis') + shouldBe = 10 + desc.getBonus('wis') if 'Perception' in desc['skills']: shouldBe += desc['skills']['Perception'] * desc['prof'] if passivePercep != shouldBe: diff --git a/parser/utils.py b/parser/utils.py index 645bc96..eb2fa67 100755..100644 --- a/parser/utils.py +++ b/parser/utils.py @@ -92,7 +92,7 @@ def getArmor(): if armor[1] == 'cost': header = armor[0] else: - armors.append({'entry': 'item', 'type': 'armor', 'name': armor[0], 'cost': cost2copper(armor[1]), 'ac': int(armor[2].split(' ')[0]), 'strength': int(armor[3].replace('-', '0').split(' ')[-1]), 'disadvantage': armor[4] == 'disadvantage', 'weight': float(armor[5].split(' ')[0]), 'armor_type': header.split(' ')[0], 'text': 'Provided from PHB'}) + armors.append({'entry': 'item', 'type': 'armor', 'name': armor[0], 'cost': cost2copper(armor[1]), 'ac': int(armor[2].split(' ')[0]), 'strength': int(armor[3].replace('-', '0').split(' ')[-1]), 'disadvantage': armor[4] == 'disadvantage', 'weight': float(armor[5].split(' ')[0]), 'armor_type': header.split(' ')[0], 'text': '. Provided from PHB.'}) return armors weapons = [] @@ -128,32 +128,34 @@ def getWeapons(): damage['dmg_die_sides'] = 1 rang = [0, 0] reach = 5 - proporties = [] + properties = [] if weapon[4] != '-': - proporties = weapon[4].split(', ') - for i, p in enumerate(list(proporties)): + properties = weapon[4].split(', ') + for i, p in enumerate(list(properties)): if 'versatile' in p: - proporties[i] = 'versatile' + properties[i] = 'versatile' elif 'range' in p: - proporties[i] = p.split(' (')[0] + properties[i] = p.split(' (')[0] rang = [int(r) for r in p.split(' ')[-1][:-1].split('/')] if 'ammunition' in p: reach = 0 elif 'reach' in p: reach += 5 if name in special: - proporties.append(special[name]) - weapons.append(formatWeapon(name, rang[0], rang[1], reach, [damage], 'Provided from PHB', {'cost': cost2copper(weapon[1]), 'weight': float(Fraction(weapon[3].split(' ')[0].replace('-', '0'))), 'properties': proporties, 'weapon_type': header})) + properties.append(special[name]) + weapons.append(formatWeapon(name, rang[0], rang[1], reach, [damage], 'Provided from PHB.', baseWeapon = {'cost': cost2copper(weapon[1]), 'weight': float(Fraction(weapon[3].split(' ')[0].replace('-', '0'))), 'properties': properties, 'weapon_type': header})) return weapons # damage is formatted {'dmg_die_count': ddc, 'dmg_die_sides': dds, 'dmg_type': dt, 'is_or': bool} -def formatWeapon(name, rangeShort, rangeLong, reach, damage, text, baseWeapon = {'cost': -1, 'weight': -1.0, 'properties': [], 'weapon_type': 'unknown'}): +def formatWeapon(name, rangeShort, rangeLong, reach, damage, text, properties=[], toHitOverride=None, abilityOverride=None, dmgBonusOverride=None, baseWeapon = {'cost': -1, 'weight': -1.0, 'weapon_type': 'unknown', 'properties': []}): + if not properties: + properties = baseWeapon['properties'] for weapon in weapons: if weapon['name'] == name: baseWeapon = weapon for dmg in damage: assert 'dmg_die_count' in dmg and 'dmg_die_sides' in dmg and 'dmg_type' in dmg and 'is_or' in dmg - return {'entry': 'item', 'type': 'weapons', 'name': name, 'cost': baseWeapon['cost'], 'damage': damage, 'weight': baseWeapon['weight'], 'range': [rangeShort, rangeLong], 'reach': reach, 'properties': baseWeapon['properties'], 'weapon_type': baseWeapon['weapon_type'], 'text': text} + return {'entry': 'item', 'type': 'weapons', 'name': name, 'cost': baseWeapon['cost'], 'damage': damage, 'weight': baseWeapon['weight'], 'range': [rangeShort, rangeLong], 'reach': reach, 'properties': properties, 'weapon_type': baseWeapon['weapon_type'], 'text': text, 'toHitOverride': toHitOverride, 'dmgBonusOverride': dmgBonusOverride, 'abilityOverride': abilityOverride} spells = [] def getSpells(): diff --git a/parser/verified.txt b/parser/verified.txt new file mode 100644 index 0000000..7a50be2 --- /dev/null +++ b/parser/verified.txt @@ -0,0 +1,261 @@ +aboleth +adult black dragon +adult blue dragon +adult brass dragon +adult bronze dragon +adult copper dragon +adult gold dragon +adult green dragon +adult red dragon +adult silver dragon +adult white dragon +air elemental +ancient black dragon +ancient blue dragon +ancient brass dragon +ancient bronze dragon +ancient copper dragon +ancient gold dragon +ancient green dragon +ancient red dragon +ancient silver dragon +androsphinx +animated armor +ankheg +ape +awakened shrub +awakened tree +axe beak +baboon +badger +balor +barbed devil +basilisk +bat +bearded devil +black bear +black dragon wyrmling +black pudding +blink dog +blood hawk +blue dragon wyrmling +boar +bone devil +brass dragon wyrmling +bronze dragon wyrmling +brown bear +bugbear +bulette +camel +cat +centaur +chain devil +chimera +chuul +clay golem +cloaker +cloud giant +cockatrice +constrictor snake +copper dragon wyrmling +couatl +crab +crocodile +darkmantle +death dog +deer +deva +dire wolf +doppelganger +draft horse +dragon turtle +dretch +drider +dust mephit +eagle +earth elemental +efreeti +elephant +elk +erinyes +ettercap +fire elemental +fire giant +flesh golem +flying snake +frost giant +gargoyle +gelatinous cube +ghast +ghoul +giant ape +giant badger +giant bat +giant boar +giant centipede +giant constrictor snake +giant crab +giant crocodile +giant eagle +giant elk +giant fire beetle +giant frog +giant goat +giant hyena +giant lizard +giant octopus +giant owl +giant poisonous snake +giant rat +giant scorpion +giant sea horse +giant shark +giant spider +giant toad +giant vulture +giant wasp +giant weasel +giant wolf spider +harpy +hawk +hell hound +hezrou +hill giant +hippogriff +homunculus +horned devil +hunter shark +hydra +hyena +ice devil +ice mephit +imp +invisible stalker +iron golem +jackal +killer whale +kraken +lamia +lemure +lich +lion +lizard +lizardfolk +magma mephit +magmin +mammoth +manticore +marilith +mastiff +medusa +merrow +mimic +minotaur +minotaur skeleton +mule +mummy +mummy lord +nalfeshnee +night hag +nightmare +ochre jelly +octopus +ogre +ogre zombie +oni +otyugh +owl +owlbear +panther +pegasus +phase spider +pit fiend +planetar +plesiosaurus +poisonous snake +polar bear +pony +pseudodragon +purple worm +quasit +quipper +rakshasa +rat +raven +red dragon wyrmling +reef shark +remorhaz +rhinoceros +riding horse +roc +roper +rug of smothering +rust monster +saber-toothed tiger +sahuagin +salamander +satyr +scorpion +sea hag +shadow +shambling mound +shield guardian +silver dragon wyrmling +solar +specter +spider +spirit naga +sprite +steam mephit +stirge +stone giant +stone golem +storm giant +succubus/incubus +swarm of bats +swarm of insects +swarm of poisonous snakes +swarm of quippers +swarm of rats +swarm of ravens +tarrasque +tiger +treant +triceratops +troll +tyrannosaurus rex +unicorn +vampire +vampire spawn +violet fungus +vrock +vulture +warhorse +warhorse skeleton +water elemental +weasel +werebear +wereboar +wererat +weretiger +werewolf +white dragon wyrmling +wight +will-o'-wisp +winter wolf +wolf +worg +wraith +wyvern +xorn +young black dragon +young blue dragon +young brass dragon +young bronze dragon +young copper dragon +young gold dragon +young green dragon +young red dragon +young silver dragon +young white dragon +zombie diff --git a/src/cmd_fsops.cc b/src/cmd_fsops.cc index 5133313..ac4bdef 100644 --- a/src/cmd_fsops.cc +++ b/src/cmd_fsops.cc @@ -21,10 +21,12 @@ namespace cmd { } else if(fs::directory_entry(truePath).is_directory()) { for(fs::directory_entry de : fs::directory_iterator(truePath)) { - if(de.is_directory()) { - text << de.path().filename().string() << "/" << std::endl; - } else { - text << de.path().stem().string() << std::endl; + if(de.path().filename().string()[0] != '.') { + if(de.is_directory()) { + text << de.path().filename().string() << "/" << std::endl; + } else { + text << de.path().stem().string() << std::endl; + } } } } diff --git a/src/creature.cc b/src/creature.cc index 5130362..6069285 100644 --- a/src/creature.cc +++ b/src/creature.cc @@ -78,6 +78,10 @@ namespace creature { for(int i = 0; i < data->hit_die_count; i++) { data->hpMax += dice::roll(data->hit_die_sides); } + // If less than zero (caused by negative con bonus), set to minimum of 1 + if(data->hpMax <= 0) { + data->hpMax = 1; + } data->hp = data->hpMax; } } @@ -227,10 +231,20 @@ namespace creature { data->hp = data->hpMax; } + int getShieldBonus(const Creature& c) { + for(auto a : utils::castPtrs<entry::Item, entry::Armor>(c.getInventory())) { + if(a->getArmorType() == "shield") { + return a->getACBonus(); + } + } + return 0; + } + const int getAC(const Creature& c) { auto natArmor = c.getNaturalArmor(); if(! natArmor.name.empty()) { - return natArmor.bonus; + // Shields stack with nat armor (see lizardfolk) + return natArmor.bonus + getShieldBonus(c); } int dex = c.getBonus(rules::Ability::Dex()); int baseBonus = 10 + dex; @@ -240,7 +254,7 @@ namespace creature { continue; } auto armorType = a->getArmorType(); - if(armorType== "misc" || armorType == "shield") { + if(armorType == "misc" || armorType == "shield") { miscBonus += a->getACBonus(); } else { baseBonus = a->getACBonus(); @@ -296,7 +310,11 @@ namespace creature { std::stringstream text; text << getGivenName() << " (" << getCreatureName() << "): " << getHP() << "/" << getHPMax() << " hp, " << getAC(*this) << " ac"; if(! getNaturalArmor().name.empty()) { - text << " (" << getNaturalArmor().name << ")"; + text << " (" << getNaturalArmor().name; + if(getShieldBonus(*this) != 0) { + text << ", shield"; + } + text << ")"; } else { std::string armor = utils::join(mapItems(utils::castPtrs<entry::Item, entry::Armor>(getInventory())), ", "); if(! armor.empty()) { @@ -318,9 +336,9 @@ namespace creature { text << std::endl; text << "Senses: "; if(! getSenses().empty()) { - text << utils::join(getSenses(), ", ") << ". "; + text << utils::join(getSenses(), ", ") << ", "; } - text << "Passive Perception " << 10 + getSkillBonus(rules::Skill::Perception()) << std::endl; + text << "Passive Perception " << 10 + getSkillBonus(rules::Skill::Perception()) + (data->observant? 5 : 0) << std::endl; if(! getLanguages().empty()) { text << "Languages: " << getLanguages() << std::endl; } diff --git a/src/item.cc b/src/item.cc index 3bb9895..5ecdb0c 100644 --- a/src/item.cc +++ b/src/item.cc @@ -11,8 +11,12 @@ using namespace std; namespace entry { shared_ptr<Item> Item::create(const nlohmann::json& data) { - if(data["type"] == "weapons") { - return utils::loadDFromJson<Item, Weapon>(data); + if(data["type"] == "weapons" || data["type"] == "spell attack") { + auto w = utils::loadDFromJson<Item, Weapon>(data); + if(! data["text"].empty()) { + w->Entry::setText(data["text"]); + } + return w; } else if(data["type"] == "armor") { return utils::loadDFromJson<Item, Armor>(data); } diff --git a/src/utils.h b/src/utils.h index 005e5be..77096e7 100644 --- a/src/utils.h +++ b/src/utils.h @@ -7,6 +7,7 @@ #include <map> #include <sstream> #include <memory> +#include <optional> #include <stdexcept> #include <filesystem> @@ -23,6 +24,23 @@ namespace nlohmann { opt = std::shared_ptr<T>(T::create(j)); } }; + + template <typename T> struct adl_serializer<std::optional<T>> { + static void to_json(json& j, const std::optional<T>& opt) { + if(opt) { + j = *opt; + } else { + j = nullptr; + } + } + static void from_json(const json& j, std::optional<T>& opt) { + if(j.is_null()) { + opt = std::optional<T>(); + } else { + opt = j.get<T>(); + } + } + }; } namespace utils { diff --git a/src/weapon.cc b/src/weapon.cc index 053dbc1..cdf4657 100644 --- a/src/weapon.cc +++ b/src/weapon.cc @@ -5,6 +5,7 @@ #include <string> #include <sstream> #include <algorithm> +#include <optional> using namespace std; @@ -17,9 +18,12 @@ namespace entry { int reach; int cost; double weight; + std::optional<int> toHitOverride; + std::optional<int> dmgBonusOverride; + std::optional<rules::Ability> abilityOverride; }; - NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(weaponImpl, damage, properties, weapon_type, range, reach, cost, weight); + NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(weaponImpl, damage, properties, weapon_type, range, reach, cost, weight, toHitOverride, dmgBonusOverride, abilityOverride); NLOHMANN_FRIEND_DEFS(Item, Weapon, data); @@ -36,7 +40,7 @@ namespace entry { string getTextHelper(const Weapon& w, string toHitBonus, string damageBonus) { stringstream text; text << "+" << toHitBonus << " to hit, "; - if(w.getReach() > 0) { + if(w.getReach() > 0 or w.getRange().second == 0) { text << "reach " << w.getReach() << " ft."; if(w.getRange().second > 0) { text << " or "; @@ -49,23 +53,43 @@ namespace entry { auto dmgs = w.getDamage(); for(size_t i = 0; i < dmgs.size(); i++) { const Damage& d = dmgs[i]; - text << d.dmg_die_count << "d" << d.dmg_die_sides; - if(i == 0) { + text << d.dmg_die_count; + if(d.dmg_die_sides > 1) { + text << "d" << d.dmg_die_sides; + } + if((i == 0 or dmgs[i].is_or) and w.getType() == "weapons" and d.dmg_die_sides != 1) { if(w.getProperties().count("versatile")) { text << " (or " << d.dmg_die_count << "d" << d.dmg_die_sides + 2 << " if two-handed)"; } - text << " + " << damageBonus; + try { + int dmgBonusInt = stoi(damageBonus); + if(dmgBonusInt > 0) { + text << " + " << dmgBonusInt; + } else if(dmgBonusInt < 0) { + text << " - " << dmgBonusInt * -1; + } // Else it's zero + } catch(exception& e) { + text << " + " << damageBonus; + } } text << " " << d.dmg_type << " damage"; if(i < dmgs.size()-1) { - if(d.is_or) { + if(dmgs[i+1].is_or) { text << " or "; } else { text << " plus "; } } } - text << "."; + if(w.Entry::getText().empty()) { + text << "."; + } else { + char first = w.Entry::getText()[0]; + if('a' <= first and 'z' >= first) { + text << ' '; + } + text << w.Entry::getText(); + } auto props = w.getProperties(); // We don't care about finesse nor versatile because they're already handled props.erase("finesse"); @@ -73,10 +97,9 @@ namespace entry { if(! props.empty()) { text << " Additional properties: " << utils::join(props, ", ") << "."; } - if(! w.Entry::getText().empty()) { - text << " " << w.Entry::getText(); + if(! w.Substantial::getText().empty()) { + text << " " << w.Substantial::getText(); } - text << " " << w.Substantial::getText(); return text.str(); } @@ -103,7 +126,7 @@ namespace entry { vector<Damage> dmgsVersatile = rollDmg(w, true); int abilityBonus = c.getBonus(creature::getBestAbility(getAbilityOptions(w), c)); for(size_t i = 0; i < dmgsNoVersatile.size(); i++) { - if(i == 0) { + if(i == 0 and w.getType() == "weapons" and dmgsNoVersatile[0].dmg_die_sides != 1) { text << dmgsNoVersatile[i].rolled + abilityBonus; if(w.getProperties().count("versatile")) { text << " (or " << dmgsVersatile[i].rolled + abilityBonus << " if two-handed)"; @@ -136,19 +159,25 @@ namespace entry { if(w.getRange().second > 0) { return {rules::Ability::Dex()}; } - cerr << "Error processing weapon!" << endl; + //cerr << "Error processing weapon: " << w.getName() << "!" << endl; + // Default to str return {rules::Ability::Str()}; } string Weapon::getText() const { auto abilities = getAbilityOptions(*this); string abilityString; - if(abilities.size() == 1) { + if(data->dmgBonusOverride) { + abilityString = to_string(*data->dmgBonusOverride); + } else if(data->abilityOverride) { + abilityString = data->abilityOverride->getAbbrev(); + } else if(abilities.size() == 1) { abilityString = string(abilities[0]); } else { abilityString = "max(" + utils::join(abilities, ", ") + ")"; } - return getTextHelper(*this, "(" + abilityString + " + prof)", abilityString); + string toHitString = data->toHitOverride ? to_string(*data->toHitOverride) : "(" + abilityString + " + prof)"; + return getTextHelper(*this, toHitString, abilityString); } @@ -157,7 +186,14 @@ namespace entry { text << getName() << " (" << getType() << "): "; // Determine best ability bonus int abilityBonus = c.getBonus(creature::getBestAbility(getAbilityOptions(*this), c)); - text << getTextHelper(*this, to_string(abilityBonus + c.getProficiency()), to_string(abilityBonus)); + if(data->abilityOverride) { + abilityBonus = c.getBonus(*data->abilityOverride); + } + string toHitString = data->toHitOverride ? to_string(*data->toHitOverride) : to_string(abilityBonus + c.getProficiency()); + if(data->dmgBonusOverride) { + abilityBonus = *data->dmgBonusOverride; + } + text << getTextHelper(*this, toHitString, to_string(abilityBonus)); return text.str(); } } |