From eb689d433061f586762797a7e3a315f00beb0547 Mon Sep 17 00:00:00 2001 From: satanas Date: Tue, 12 Nov 2019 19:04:58 -0800 Subject: [PATCH] Added ability_check. Added tests. Updated readme --- README.md | 11 ++++--- commands.py | 12 +++++--- exceptions.py | 5 +++ handlers/character.py | 53 ++++++++++++++++++++++++++++---- main.py | 6 ++-- models/character.py | 68 +++++++++++++++++++++++++++++++++++++++-- tests/test_character.py | 53 +++++++++++++++++++++++++++++++- utils.py | 5 +++ 8 files changed, 190 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 421cb3e..95e5308 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,13 @@ Character commands | Action /link_char \, (username) | links character to target username or self username /status \ | shows the list of weapons of a character /weapons \ | shows the list of weapons of a character -/attack_roll \, \, \, (distance), (adv\|disadv) | performs an attack roll on a character +/attack_roll \, \, (distance), (adv\|disadv) | performs an attack roll on a character /initiative_roll \ | performs an initiative roll for a character /short_rest_roll \ | performs an short rest roll for a character -/talk \ | prints a message using in-game conversation format -/say \ | prints a normal message using in-game conversation format -/whisper \ | prints a whisper message using in-game conversation format -/yell \ | prints a yell message using in-game conversation format +/ability_check \, (skill) | performs an ability check or a skill check if skill is specified +/say \, \ | prints a message using in-game conversation format +/whisper \, \ | prints a whisper message using in-game conversation format +/yell \, \ | prints a yell message using in-game conversation format ## What do I need? @@ -106,3 +106,4 @@ AWS credentials saved on your machine at ~/.aws/credentials. ## References * https://github.com/python-telegram-bot/python-telegram-bot/blob/master/examples/README.md * https://github.com/treetrnk/rollem-telegram-bot/blob/master/bot.py +* https://rpg.stackexchange.com/questions/83930/how-do-i-calculate-my-skill-modifier diff --git a/commands.py b/commands.py index 5ffad81..af64809 100644 --- a/commands.py +++ b/commands.py @@ -42,9 +42,10 @@ "/attack_roll": (character_handler, ["", "", "(distance)", "(adv|disadv)"], "performs an attack roll on a character"), "/initiative_roll": (character_handler, [""], "performs an initiative roll for a character"), "/short_rest_roll": (character_handler, [""], "performs an short rest roll for a character"), - "/say": (character_handler, [""], "prints a message using in-game conversation format"), - "/whisper": (character_handler, [""], "prints a whisper message using in-game conversation format"), - "/yell": (character_handler, [""], "prints a yell message using in-game conversation format"), + "/ability_check": (character_handler, ["", "(skill)"], "performs an ability check or a skill check if skill is specified"), + "/say": (character_handler, ["", ""], "prints a message using in-game conversation format"), + "/whisper": (character_handler, ["", ""], "prints a whisper message using in-game conversation format"), + "/yell": (character_handler, ["", ""], "prints a yell message using in-game conversation format"), } ALL_COMMANDS = {} @@ -53,8 +54,9 @@ ALL_COMMANDS.update(CHARACTER_COMMANDS) def command_handler(command): - if command == "/help": - return default_handler + if command == "/help" or command == '/start': + raise CommandNotFound + # return default_handler #elif command == "/start": elif command in ALL_COMMANDS: return ALL_COMMANDS[command][0] diff --git a/exceptions.py b/exceptions.py index d2fe709..be49161 100644 --- a/exceptions.py +++ b/exceptions.py @@ -11,4 +11,9 @@ class CampaignNotFound(Exception): pass class CharacterNotFound(Exception): + """Raised when a character is not defined or doesn't exist in the database""" + pass + +class InvalidCommand(Exception): + """Raised when a command doesn't have a valid structure""" pass diff --git a/handlers/character.py b/handlers/character.py index 58dc8c4..731511b 100644 --- a/handlers/character.py +++ b/handlers/character.py @@ -6,9 +6,9 @@ from handlers.roll import roll from database import Database -from models.character import Character -from exceptions import CharacterNotFound, CampaignNotFound from utils import normalized_username +from models.character import Character, ABILITIES, SKILLS +from exceptions import CharacterNotFound, CampaignNotFound, InvalidCommand CLOSE_COMBAT_DISTANCE = 5 # feet @@ -46,6 +46,8 @@ def handler(bot, update, command, txt_args): elif command == '/say' or command == '/yell' or command == '/whisper': response = talk(command, txt_args) #bot.delete_message(chat_id=update.message.chat_id, message_id=update.message.message_id) + elif command == '/ability_check': + response = ability_check(txt_args, db, chat_id, username) bot.send_message(chat_id=update.message.chat_id, text=response, parse_mode="Markdown") @@ -114,7 +116,7 @@ def attack_roll(txt_args, db, chat_id, username): return f"You can attack a target beyond the range of your weapon ({weapon_name}, {weapon.long_range}ft)" prof = " + PRO(0)" - if character.has_weapon_proficiency(weapon_name): + if character.has_proficiency(weapon_name): mods += character.proficiency prof = f" + PRO({character.proficiency})" @@ -167,7 +169,7 @@ def initiative_roll(txt_args, db, chat_id, username): dice_notation = f'1d20+{character.dex_mod}' results = roll(dice_notation) dice_rolls = results[list(results.keys())[0]][0] - return f'@{username} initiave roll for {character.name} ({dice_notation}): {dice_rolls}' + return f'@{username} initiative roll for {character.name} ({dice_notation}): {dice_rolls}' def short_rest_roll(txt_args, db, chat_id, username): character = get_linked_character(db, chat_id, username) @@ -182,6 +184,7 @@ def short_rest_roll(txt_args, db, chat_id, username): def get_weapons(other_username, db, chat_id, username): search_param = other_username if other_username != '' else username + search_param = utils.normalized_username(search_param) character = get_linked_character(db, chat_id, search_param) if len(character.weapons) > 0: @@ -213,8 +216,46 @@ def talk(command, txt_args): return f"```\r\n{character_name} says:\r\n–{message}\r\n```" -def ability_check(chat_id, username, ability): - pass +def ability_check(txt_args, db, chat_id, username): + args = txt_args.split(' ') + if len(args) == 0: + return ('Invalid syntax. Usage:' + '\r\n/ability_check (skill)') + + skill = None + ability = args[0].lower() + base_notation = '1d20' + if len(args) == 2: + skill = args[1].lower() + + if ability not in ABILITIES: + return ('Invalid ability. Supported options: ' + ', '.join(ABILITIES)) + if skill is not None and skill not in SKILLS[ability]: + return ('Invalid skill. Supported options: ' + ', '.join(SKILLS[ability])) + + character = get_linked_character(db, chat_id, username) + + txt_skill_mod = '' + txt_ability_mod = f' + {ability.upper()}({character.mods[ability]})' + ability_desc = ability.upper() + mods = character.mods[ability] + if skill is not None: + ability_desc = f'{ability_desc} ({skill.capitalize()})' + mods += character.mods[skill] + txt_skill_mod = f' + {skill.capitalize()}({character.mods[skill]})' + + txt_formula = f"{base_notation}{txt_ability_mod}{txt_skill_mod}" + if mods > 0: + dice_notation = f"{base_notation}+{mods}" + else: + dice_notation = base_notation + + results = roll(dice_notation) + dice_rolls = results[list(results.keys())[0]] + + return (f"@{username} ability check for {character.name} with {ability_desc}:" + f"\r\nFormula: {txt_formula}" + f"\r\n*{dice_notation}*: {dice_rolls}") def get_linked_character(db, chat_id, username): campaign_id, campaign = db.get_campaign(chat_id) diff --git a/main.py b/main.py index cd4912d..71fe203 100644 --- a/main.py +++ b/main.py @@ -13,8 +13,8 @@ if logger.handlers: for handler in logger.handlers: logger.removeHandler(handler) -logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - level=logging.INFO) +#logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO) +logging.basicConfig(format='%(message)s', level=logging.INFO) OK_RESPONSE = { 'statusCode': 200, @@ -45,7 +45,7 @@ def webhook(event, context): """ bot = configure_telegram() - logger.info('Event: {}'.format(event)) + logger.info(json.loads(event.get('body'))) if event.get('httpMethod') == 'POST' and event.get('body'): update = telegram.Update.de_json(json.loads(event.get('body')), bot) diff --git a/models/character.py b/models/character.py index 6a29e30..ef46716 100644 --- a/models/character.py +++ b/models/character.py @@ -1,8 +1,49 @@ import math +import utils + from models.armor import Armor from models.weapon import Weapon +ABILITIES = [ + 'str', + 'dex', + 'int', + 'wis', + 'cha' +] + +SKILLS = { + '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', + 'persuasion' + ] +} + class Character: def __init__(self, json_data, race_data, by_id): character = json_data if by_id else json_data['character'] @@ -33,13 +74,15 @@ def __init__(self, json_data, race_data, by_id): self.initiative = self.dex_mod self.weapons = [Weapon(x) for x in character['inventory'] if x['definition']['filterType'] == "Weapon"] self.armor = [Armor(x) for x in character['inventory'] if x['definition']['filterType'] == "Armor"] - self.proficiencies = [x['friendlySubtypeName'] for x in character['modifiers']['class'] if x['type'] == 'proficiency'] + self.proficiencies = [x['subType'] for x in character['modifiers']['class'] if x['type'] == 'proficiency'] self.size = character['race']['size'] self.proficiency = math.floor((self.level + 7) / 4) + self.mods = self.__calculate_modifiers() + - def has_weapon_proficiency(self, weapon): - return True if weapon in self.proficiencies else False + def has_proficiency(self, arg): + return True if utils.to_snake_case(arg) in self.proficiencies else False def get_weapon(self, weapon_name): result = [w for w in self.weapons if w.name == weapon_name] @@ -48,6 +91,25 @@ def get_weapon(self, weapon_name): else: return None + def __calculate_modifiers(self): + mods = { + 'str': self.str_mod, + 'dex': self.dex_mod, + 'con': self.con_mod, + 'int': self.int_mod, + 'wis': self.wis_mod, + 'cha': self.cha_mod + } + + for ability in ABILITIES: + for skill in SKILLS[ability]: + mods[skill] = mods[ability] + if self.has_proficiency(skill): + mods[skill] += self.proficiency + + return mods + + def __str__(self): return (f"Character name={self.name}, race={self.race}, str={self.str}({self.str_mod}), dex={self.dex}({self.dex_mod}), " f"con={self.con}({self.con_mod}), int={self.int}({self.int_mod}), wis={self.wis}({self.wis_mod}), " diff --git a/tests/test_character.py b/tests/test_character.py index 1e86292..ddd311b 100644 --- a/tests/test_character.py +++ b/tests/test_character.py @@ -4,7 +4,7 @@ from unittest.mock import patch, Mock, PropertyMock from models.character import Character -from handlers.character import talk, import_character, link_character, get_status +from handlers.character import talk, import_character, link_character, get_status, ability_check CHARACTER_JSON = { 'character': { @@ -150,6 +150,57 @@ def test_status_with_params(self): db.get_character_id.assert_called_with(campaign_id, 'foobar') self.assertEqual('Amarok Skullsorrow | Human Sorcerer Level 1\r\nHP: 6/6 | XP: 25', rtn) + def test_ability_check_with_empty_params(self): + # conditions + chat_id = 123456 + username = 'foo' + db = Mock() + + # execution + rtn = ability_check('', db, chat_id, username) + self.assertEqual('Invalid ability. Supported options: str, dex, int, wis, cha', rtn) + + def test_ability_check_with_ability(self): + # conditions + character_id = 987654321 + campaign_id = 666 + chat_id = 123456 + username = 'foo' + db = Mock() + db.get_campaign = Mock(return_value=(campaign_id, None)) + db.get_character_id = Mock(return_value=(character_id)) + db.get_character = Mock(return_value=self.__get_character()) + + # execution + rtn = ability_check('str', db, chat_id, username) + self.assertEqual(True, rtn.find("@foo ability check for Amarok Skullsorrow with STR:\r\nFormula: 1d20 + STR(4)\r\n*1d20+4*") == 0) + + def test_ability_check_with_ability_and_skill(self): + # conditions + character_id = 987654321 + campaign_id = 666 + chat_id = 123456 + username = 'foo' + db = Mock() + db.get_campaign = Mock(return_value=(campaign_id, None)) + db.get_character_id = Mock(return_value=(character_id)) + db.get_character = Mock(return_value=self.__get_character()) + + # execution + rtn = ability_check('wis perception', db, chat_id, username) + self.assertEqual(True, rtn.find("@foo ability check for Amarok Skullsorrow with WIS (Perception):\r\nFormula: 1d20 + WIS(1) + Perception(1)\r\n*1d20+2*") == 0) + + def test_ability_check_with_ability_and_invalid_skill(self): + # conditions + chat_id = 123456 + username = 'foo' + db = Mock() + + # execution + rtn = ability_check('str perception', db, chat_id, username) + self.assertEqual('Invalid skill. Supported options: athletics', rtn) + + def __get_character(self): with open('tests/fixtures/character.json', 'r') as jd: json_data = json.loads(jd.read()) diff --git a/utils.py b/utils.py index 3c599e9..16ff8b5 100644 --- a/utils.py +++ b/utils.py @@ -1,2 +1,7 @@ def normalized_username(username): return username.replace('@', '').strip() + +def to_snake_case(text): + text = text.lower() + text = text.replace(' ', '-') + return text