aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYour Name <you@example.com>2021-06-12 15:32:53 -0400
committerYour Name <you@example.com>2021-06-12 15:32:53 -0400
commit01293baa64fa905c5763020bd6c0b4903d41fc78 (patch)
tree4c49a63852fd84ead388a8fd092d64d2df7f9e1b
parentb27700a7e0b281ece3dea23060c17e0cae28715d (diff)
downloaddmtool-01293baa64fa905c5763020bd6c0b4903d41fc78.tar.gz
dmtool-01293baa64fa905c5763020bd6c0b4903d41fc78.tar.bz2
dmtool-01293baa64fa905c5763020bd6c0b4903d41fc78.zip
Verified some creature attacks
-rw-r--r--Makefile2
-rwxr-xr-xconfigure2
m---------parser/5thSRD0
-rw-r--r--parser/attacks.py188
-rwxr-xr-xparser/scrapeToJson.py156
-rw-r--r--[-rwxr-xr-x]parser/utils.py22
-rw-r--r--parser/verified.txt261
-rw-r--r--src/cmd_fsops.cc10
-rw-r--r--src/creature.cc28
-rw-r--r--src/item.cc8
-rw-r--r--src/utils.h18
-rw-r--r--src/weapon.cc66
12 files changed, 591 insertions, 170 deletions
diff --git a/Makefile b/Makefile
index a7caaae..e075633 100644
--- a/Makefile
+++ b/Makefile
@@ -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/
diff --git a/configure b/configure
index a74adcb..179ee83 100755
--- a/configure
+++ b/configure
@@ -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();
}
}