├── .gitattributes ├── .gitignore ├── .npmrc ├── ISSUE_TEMPLATE.md ├── LICENSE ├── README.md ├── bot.js ├── config.json ├── discordlib ├── chatter.js ├── discordroles.js └── index.js ├── dungeonary ├── .npmrc ├── 5e │ ├── OGL_WIZARDS.txt │ ├── monsters.json │ └── spells.json ├── README.md ├── adventuregen.js ├── beastiary.js ├── data │ ├── 5e-monsters.json │ ├── 5e-spells.json │ └── highFantasyArcs.json ├── diceroller.js ├── index.js ├── original │ └── highFantasyArcs.json ├── package.json └── spellbook.js ├── gravemind ├── index.js └── sysinfo.js ├── jsbeautifyrc ├── package-lock.json ├── package.json ├── py-generators ├── character.py ├── chargen.py ├── dungeon.py └── monster.py ├── randomUtil.js ├── service.sh ├── service ├── autoupdate.sh ├── cron_templates ├── install-service.sh ├── install-tavernbot.sh ├── logrotate.tavern └── tavernbot.service └── test.js /.gitattributes: -------------------------------------------------------------------------------- 1 | py-generators/* linguist-vendored 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | package.old 2 | 3 | *.iml 4 | .idea/ 5 | *.pyc 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | node_modules/ 17 | .npm 18 | .node_repl_history 19 | .env 20 | .config 21 | 22 | .token.json 23 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Expand here on your title, unless the title is clear enough. Please delete any template lines that you aren't using - note that this is a suggested format, and though it's nice, you're not bound to it :) 2 | 3 | ### Bug Alert 4 | - What is the issue? 5 | - How do we replicate it? 6 | - How many donuts will you send if we fix it now instead of backlogging it? 7 | 8 | ### Feature Request 9 | - What should the feature do? 10 | - Is it for gaming, for discord admins, or for fun/novelty? 11 | 12 | ### Idea, Question, Suggestion? 13 | Feel free to adlib here! 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright jakethedev (c) 2017-2018 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TavernBot - The Ultimate RPG Discord Bot for D&D, Pathfinder, and beyond! 2 | ### [Ideas and Suggestions Welcome!](https://github.com/jakethedev/tavernbot/issues) 3 | 4 | The idea: Wouldn't it be great to have a really good D&D bot for discord? I think so. And this is the start of that solution. 5 | 6 | TavernBot is a generator bot for Game Masters of D&D. This project is a Discord bot wrapper for the [Dungeonary](https://www.npmjs.com/package/dungeonary), and several other useful discord functions such as roles and eventually music/ambience in a voice channel. 7 | 8 | This is currently an 0.x release - I will document features and such here as they're finished up and as we close in on 1.0, but for now, I'm pouring molten free time into this project to try and forge something awesome. Check the code for now if you want to know more. 9 | 10 | ## Development 11 | 12 | Fork this project, then follow these steps to get up and running! Make sure you have node 8+, with an appropriate npm, and a Discord bot token. Go to [this link](https://discordapp.com/developers/applications/me) to set up a bot account, add the token to config.json in the project root, then invite the bot to a server for testing. And don't forget npm install. 13 | 14 | 'npm run devbot' will set you up with a hot-reloading bot instance, and 'npm test' should run quietly with no issues if everything's set up correctly. 'npm run verbosetest' will show you the npm test output, which should look like Discord-formatted responses. 15 | 16 | ### Expectations and how it loads 17 | 18 | The bot is set up to load a list of local libs, grab every exported function, and drop the functions + a bit of metadata into a global commander object. That said, this means it calls all functions exactly the same way - and if you need more parameters for some reason, perhaps we should chat. For your new commands to drop in and immediately work, they must have the following signature: `f(input:String, message:discord.js#message, client:discord.js#client)` - input will be everything after your commands name in the message to the bot (like '!commandname input is all this stuff'), the message will be the full message object [per the Discord.js api](https://discord.js.org/#/docs/main/stable/class/Message), and the client is [from Discord.js too](https://discord.js.org/#/docs/main/stable/class/Client). 19 | 20 | ### Writing new commands 21 | 22 | If you just want to just *add a relevant command* to a library, you only need *step 4*. But if you have commands to add that don't seem to fit with the theme of functions in a particular file, follow all of these steps to add a new library folder to the bot: 23 | 24 | 1. Make a new directory 25 | 2. Add your new directory to the MODULES array in bot.js 26 | 3. Copy index.js from discordlib or gravemind into your new lib as a handy piece of boilerplate 27 | 4. Write exported functions in your library (Note: The bot ignores the default export!) 28 | 5. Update the index.js in your library so it loads a file you create in your new lib 29 | 6. Run it! You've now added functionality to the bot! 30 | 31 | ## Development triage: 32 | 33 | ### ImportError: no module compiler.ast: 34 | 35 | If you see the above issue during 'npm install', just run 'sudo apt install python-dev'. I'm as upset as you are that we need python for npm, but, c'est la vie. 36 | 37 | ### Vague "app crashed" error 38 | 39 | An issue with the bot, while testing new commands, is that you have to be very aware of what might throw an error. I don't have error handling set up correctly yet, even though I'm following the recommended client.on('error', callback) approach, so I apologize if this bites you. If you know a way to make node/discord.js run in a hella verbose way, I'd gladly add that to the `npm run devbot` script 40 | 41 | --- 42 | 43 | Below is a sort of notepad, and generally contains nothing useful. If you have ideas or features that you think this bot should support, [let me know on Github](https://github.com/jakethedev/tavernbot/issues) and we'll get it prioritized :D 44 | 45 | Source for dungeon world content: https://www.npmjs.com/package/dungeonworld-data 46 | 47 | Voice API https://discord.js.org/#/docs/main/stable/topics/voice 48 | 49 | Character stats implementation needs PHB spread w/stat priority per class 50 | 51 | Keep last 128 roll macros? 256? 52 | 53 | Find a good base of Character Sheet JSON 54 | 55 | Possibly useful for inspiration https://github.com/opendnd 56 | 57 | -------------------------------------------------------------------------------- /bot.js: -------------------------------------------------------------------------------- 1 | // Core bot setup 2 | require('./randomUtil') 3 | const fs = require('fs') 4 | const config = require('./config.json') 5 | const { token } = require('./.token.json') 6 | const discord = require("discord.js") 7 | const client = new discord.Client() 8 | 9 | // Dynamically load all operations we care about into a single commander object 10 | loadAllOperations = function(libNames){ 11 | let allOps = {}, meta = {} 12 | // Get each lib by name 13 | for (lib of libNames) { 14 | meta[lib] = [] 15 | let libOps = require(lib); 16 | for (op in libOps) { 17 | // Stash all op names at meta[libname] for help reference 18 | allOps[op] = libOps[op] 19 | meta[lib].push(op) 20 | } 21 | // These will clobber eachother, this keeps them split up 22 | meta[lib].helptext = libOps['helptext']() 23 | } 24 | return [ allOps, meta ] 25 | } 26 | // Always keep gravemind at the end 27 | const MODULES = [ './discordlib', './dungeonary', './gravemind' ] 28 | const [ commander, metadata ] = loadAllOperations(MODULES) 29 | 30 | // In case something happens, we'll want to see logs 31 | client.on("error", (e) => console.error(e)) 32 | 33 | // Startup callback 34 | client.on('ready', () => { 35 | if (process.env.NODE_ENV) { 36 | console.log(`${process.env.NODE_ENV} mode activated!`) 37 | } else { 38 | console.log(`NODE_ENV not set, running in dev mode`) 39 | } 40 | console.log(`Tavernbot v${config.version} has logged in as ${client.user.tag}!`) 41 | client.user.setPresence({ 42 | "status": "online", 43 | "game": { "name": config.gameStatus } 44 | }) 45 | }) 46 | 47 | // Command central 48 | client.on('message', msg => { 49 | // Contain the bot, and ensure we actually want to act on the command 50 | let channelName = msg.channel.name ? msg.channel.name.toLowerCase() : "NOT_A_CHANNEL_NAME" 51 | if (config.activeChannels.includes(channelName) || msg.channel.recipient) { 52 | if (!msg.content.trim().startsWith(config.botkey) || msg.author.bot) return 53 | // Normalize input 54 | let parts = msg.content.trim().toLowerCase().substring(1).split(/\s+/) 55 | let cmd = parts[0] 56 | let input = parts[1] ? parts.slice(1).join(' ') : '' //Some cmds have no input, this lets us use if(input) 57 | let execTime = new Date(Date.now()).toLocaleString(); 58 | // If we have the requested op, send it - otherwise, log it quietly 59 | if (cmd in commander) { 60 | console.log(execTime + ': running ' + cmd + '(' + input + ') for ' + msg.author.username) 61 | // Works for a string or a promise return. Sick. https://stackoverflow.com/a/27760489 62 | Promise.resolve( commander[cmd](input, msg, client) ) 63 | .then(function(result) { 64 | msg.reply(result) 65 | }) 66 | .catch(function(err) { 67 | msg.reply(`your command met with a terrible fate and I nearly died. Have an admin check the logs plz`) 68 | console.log(`${execTime}: ERR: ${err}`) 69 | }) 70 | } else if (cmd == 'help') { 71 | let fullHelp = `these are my powers:` 72 | // Each library is a string 73 | for (library in metadata){ // Already overloaded command, oops 74 | fullHelp += `\n**${metadata[library].helptext}**: \n` // Set in each lib's index.js, saved at :17 75 | // meta[lib] is a list of ops in that lib 76 | for (var opName of metadata[library]) { 77 | if ((opName) != 'helptext') 78 | fullHelp += `${opName}\n` 79 | } 80 | } 81 | fullHelp += `\nFor any command, run '${config.botkey}command help' for detailed use info. ` 82 | fullHelp += `If you notice something weird or broken, run **${config.botkey}feedback** for support info` 83 | msg.channel.send(fullHelp) 84 | } else { 85 | console.log(`${execTime}: NOTICE: can't find ${cmd}(${input}) for ${msg.author.username}`) 86 | } 87 | } 88 | }); 89 | 90 | // Turning the key and revving the bot engine 91 | client.login(token) 92 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.1.5", 3 | "releasenotes": "Less broken dice roller!", 4 | "issuelink": "https://github.com/jakethedev/tavernbot/issues", 5 | "botkey": "!", 6 | "activeChannels": ["golemworks", "bot", "botspam", "unnamed-campaign"], 7 | "gameStatus": "tavernbot | !help for info" 8 | } 9 | -------------------------------------------------------------------------------- /discordlib/chatter.js: -------------------------------------------------------------------------------- 1 | const rand = require('../randomUtil') 2 | 3 | exports.sass = function(input, message) { 4 | if (input.toLowerCase() == 'help') return `yeah, I bet you need help` 5 | let raresassings = [ 6 | `you just lost the game`, 7 | `I wish you a lifetime of flowers that say "she loves me not"`, 8 | `you're my favorite person besides every other person I've met`, 9 | `ah, you scared me, that must be one of those faces that only a mother can love`, 10 | `you're impossible to underestimate`, 11 | ] 12 | let sassings = [ 13 | `seems to be up to no good, now I want to ban them from my neighborhood`, 14 | `you smell like you sleep outside`, 15 | `well aren't *you* delightful`, 16 | `oh no, its you`, 17 | `you know, you should probably be working`, 18 | `sorry, I was distracted by your weird hair, what?`, 19 | `I hope you get banned`, 20 | `get lost`, 21 | `ain't nobody got time for sassposts`, 22 | `I don't get paid enough to sass you`, 23 | `*don't you have an app for that?*`, 24 | `your father was an elderberry`, 25 | `your mother smelt of hamsters`, 26 | `sass is the only dish on the menu and you just got SERVED`, 27 | `today's main course is sassage with a side of sassbrowns`, 28 | `and then, a good *sassing*!`, 29 | `I think we've had enough, check please`, 30 | `nah` 31 | ] 32 | let sassindex = randIntMinOne(1000) 33 | if (sassindex < 20) { 34 | return rand.choice(raresassings) + ' /r' 35 | } else { 36 | return rand.choice(sassings) 37 | } 38 | } 39 | 40 | exports.summon = function(input, message) { 41 | if (input.toLowerCase() == 'help') return `'summon tag-a-user' will spontaneously generate a summoning circle for your user of choice` 42 | if (message.mentions.users) { 43 | if (message.mentions.users.size == 1) { 44 | let summoned = message.mentions.members.first() 45 | // The summon circle is ~36 chars wide, 18 or 19 is the mid point 46 | let padding = ' '.repeat(18 - (summoned.displayName.length / 2)) 47 | return `your wish is my command... 48 | 49 | COME FORTH ${summoned}! \`\`\` 50 | %#% %#% 51 | %#% %#% 52 | 53 | %#% %#% 54 | 55 | %#% %#% 56 | ${padding + summoned.displayName} 57 | %#% %#% 58 | 59 | %#% %#% 60 | 61 | %#% %#% 62 | %#% %#%\`\`\`` 63 | } 64 | } 65 | return "you can't just summon nothing, that's not how this works!" 66 | } 67 | -------------------------------------------------------------------------------- /discordlib/discordroles.js: -------------------------------------------------------------------------------- 1 | // See readme for more info, you can return raw data or a promise and the bot will reply 2 | // with either the data or the resolution of the promise 3 | 4 | exports.newrole = function(input, message, client) { 5 | if (input.toLowerCase() == 'help') return `'newrole new-role-name' will create a new role with the same permissions as the everybody role` 6 | if (!message.member) return `you don't even live here, stop messing with things (err: not a server member)` 7 | // if (message.member.permissions.includes 'icandothings'){ 8 | // discord.addNewRole(name: input).copyFrom(everyone).randomColor() 9 | // return `mission accomplished - your role called "${input}" is ready to rock` 10 | // } 11 | return `under construction (nothing to see here)` 12 | // return `put that back, you're not allowed to touch it. (err: you don't have permission)` 13 | } 14 | 15 | //Given a rolename as input, add it to the requestor if it doesn't result in new privileges 16 | exports.giverole = function(input, message, client) { 17 | if (input.toLowerCase() == 'help') return `Usage: addrole/giverole 'the role name' will try to add the role to your user. Optionally, you can tag one person (after the role name) to attempt to give them the role` 18 | if (!input.trim()) return `you can't just add nothing as a role, that's not how any of this works!` 19 | let expectedRoleName = input.split('<')[0].toLowerCase().trim() //Expecting only one role, before any mentions 20 | // Allows us to add a role to someone, pinging them required 21 | if (message.guild) { 22 | let requestorName = message.member.user.username 23 | let optionalMention = message.mentions.members.first() 24 | let targetMember = optionalMention ? optionalMention : message.member 25 | let targetName = targetMember.user.username 26 | let roleToAdd = message.guild.roles.find((role) => expectedRoleName == role.name.toLowerCase()) 27 | if (!roleToAdd){ 28 | return `that role does not exist, checkest thy typing or speaketh with thy lord moderators` 29 | } 30 | console.log(`Role '${roleToAdd.name}' requested by ${requestorName} for ${targetName}...`) 31 | return targetMember.addRole(roleToAdd).then(result => { 32 | // S'gooood. This is idempotent, adding an existing role id a-ok 33 | return `${targetName} now has (or already had) the role ${roleToAdd.name}!` 34 | }).catch(err => { 35 | // Almost certainly a permission error 36 | return `I can't add ${targetName} to ${roleToAdd.name}, probably not allowed to. Contact an admin if this is unexpected` 37 | }); 38 | } else { 39 | return `run this command on a server with roles to get a more helpful response :)` 40 | } 41 | } 42 | 43 | // List roles on the server that the bot can assign 44 | exports.allroles = function(input, message, client) { 45 | if (input.toLowerCase() == 'help') 46 | return `'roles' will get you a list of the server roles that I can grant you` 47 | // If we're on a server, get them roles - reply intelligently in unhappy circumstances 48 | if (message.guild) { 49 | const roleList = message.guild.roles 50 | .filter(role => !role.name.includes('@everyone')) 51 | .map(role => `'${role.name}'`) 52 | .sort() 53 | .join(', ') 54 | if(roleList) { 55 | return `a server has many roles:\n${roleList}` 56 | } else { 57 | return `a server has no roles, bribe a mod to add one` 58 | } 59 | } else { 60 | return `run this command on a server with roles to get a more helpful response :)` 61 | } 62 | } 63 | 64 | //List the requestor's roles 65 | exports.myroles = function(input, message, client) { 66 | if (input.toLowerCase() == 'help') return `'myroles' will list all roles you have here` 67 | if (message.guild) { 68 | const userRolesRaw = message.member.roles 69 | let roleResults = [] 70 | // Stash the results, strip any @ symbols to avoid pinging @everyone every single time 71 | userRolesRaw.forEach(role => roleResults.push(role.name.replace('@', ''))) 72 | if (roleResults[0]) { 73 | return `here are your roles: [${roleResults.join(', ')}]` 74 | } else { 75 | return `your purpose is to butter toast. (no roles found)` 76 | } 77 | } else { 78 | return `run this command on a server with roles to get a more helpful response :)` 79 | } 80 | } 81 | 82 | //Self-remove a role, after verifying that the author is a member (role-bearer) 83 | exports.unrole = function(input, message, client) { 84 | if (!input || input.toLowerCase() == 'help') return `'unrole rolename' will rolename from your roles if you have it` 85 | if (message.guild) { 86 | let userRoles = message.member.roles 87 | if (userRoles.size == 0) 88 | return `it seems that you have no roles, and that's really funny` 89 | let roleResult = userRoles.find(role => role.name.toLowerCase() === input.toLowerCase()) 90 | return message.member.removeRole(roleResult).then(result => { 91 | return `you are uninvited from ${input}` 92 | }).catch(error => { 93 | return `I'm afraid I can't do that, Dave. Either you don't have that role or a mod needs to handle it` 94 | }) 95 | } else { 96 | return `run this command on a server with roles to get a more helpful response :)` 97 | } 98 | } 99 | 100 | //Number of people in a given role 101 | exports.rolesize = function(input = '', message, client) { 102 | if (!input) return `give me a role and I'll give you an answer` 103 | if (input.toLowerCase() == 'help') return `'rolesize role-name' prints the size of a role - this might go away soon though` 104 | if (message.guild) { 105 | let roleResult = getGuildRole(input, message) 106 | if (roleResult) { 107 | let roleCount = roleResult.members.size 108 | return `there are ${roleCount} members in ${roleResult.name}` 109 | } else { 110 | return `role not found - gimme the role's full name, and I'll get you a member count` 111 | } 112 | } else { 113 | return `run this command on a server with roles to get a more helpful response :)` 114 | } 115 | } 116 | 117 | //List people in a given role 118 | exports.rolemembers = function(input = '', message, client) { 119 | if (!input) return `give me a role and I'll give you an answer` 120 | if (input.toLowerCase() == 'help') return `'rolemembers role-name' definitely doesn't list the members of a role` 121 | if (message.guild) { 122 | let role = getGuildRole(input, message) 123 | if (role && role.members.size) { 124 | let roleMemberList = role.members.map(m => m.displayName).join(', ') 125 | return `${role.name} has the following members:\n${roleMemberList}` 126 | } else if (role) { 127 | return `${role.name} is a quiet, empty place with no members` 128 | } else { 129 | return `role '${input}' not found - I need the role's full name to get you a roll call` 130 | } 131 | } else { 132 | return `run this command on a server with roles to get a more helpful response :)` 133 | } 134 | } 135 | 136 | //////////////////////////// 137 | // Internal Helper Functions 138 | //////////////////////////// 139 | 140 | // Takes in the name of a role and a discord message 141 | function getGuildRole(input, message) { 142 | input = input.trim().toLowerCase() 143 | return message.guild.roles.find(role => role.name.toLowerCase() === input) 144 | } -------------------------------------------------------------------------------- /discordlib/index.js: -------------------------------------------------------------------------------- 1 | // List all local components here 2 | const components = [ 3 | './discordroles', 4 | './chatter' 5 | ] 6 | 7 | // Go through each chunk of the library and set each exported 8 | // function as its own export of this module 9 | for (sublib of components) { 10 | let lib = require(sublib) 11 | for (operation in lib) { 12 | exports[operation] = lib[operation] 13 | } 14 | console.log(sublib + " loaded!") 15 | } 16 | exports['helptext'] = () => "Role and Chatter Commands" -------------------------------------------------------------------------------- /dungeonary/.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /dungeonary/5e/OGL_WIZARDS.txt: -------------------------------------------------------------------------------- 1 | OPEN GAME LICENSE Version 1.0a 2 | The following text is the property of Wizards of the Coast, Inc. and is Copyright 2000 Wizards of the Coast, Inc (Wizards). All Rights Reserved. 3 | 1. Definitions: (a)Contributors means the copyright andgor trademark owners who have contributed Open Game Content; (b)Derivative Material means copyrighted material including derivative works and translations (including into other computer languages), potation, modification, correction, addition, extension, upgrade, improvement, compilation, abridgment or other form in which an existing work may be recast, transformed or adapted; (c) Distribute means to reproduce, license, rent, lease, sell, broadcast, publicly display, transmit or otherwise distribute; (d)Open Game Content means the game mechanic and includes the methods, procedures, processes and routines to the extent such content does not embody the Product Identity and is an enhancement over the prior art and any additional content clearly identified as Open Game Content by the Contributor, and means any work covered by this License, including translations and derivative works under copyright law, but specifically excludes Product Identity. (e) Product Identity means product and product line names, logos and identifying marks including trade dress; artifacts; creatures characters; stories, storylines, plots, thematic elements, dialogue, incidents, language, artwork, symbols, designs, depictions, likenesses, formats, poses, concepts, themes and graphic, photographic and other visual or audio representations; names and descriptions of characters, spells, enchantments, personalities, teams, personas, likenesses and special abilities; places, locations, environments, creatures, equipment, magical or supernatural abilities or effects, logos, symbols, or graphic designs; and any other trademark or registered trademark clearly identified as Product identity by the owner of the Product Identity, and which specifically excludes the Open Game Content; (f) Trademark means the logos, names, mark, sign, motto, designs that are used by a Contributor to identify itself or its products or the associated products contributed to the Open Game License by the Contributor (g) Use, Used or Using means to use, Distribute, copy, edit, format, modify, translate and otherwise create Derivative Material of Open Game Content. (h) You or Your means the licensee in terms of this agreement. 4 | 2. The License: This License applies to any Open Game Content that contains a notice indicating that the Open Game Content may only be Used under and in terms of this License. You must affix such a notice to any Open Game Content that you Use. No terms may be added to or subtracted from this License except as described by the License itself. No other terms or conditions may be applied to any Open Game Content distributed using this License. 5 | 3.Offer and Acceptance: By Using the Open Game Content You indicate Your acceptance of the terms of this License. 6 | 4. Grant and Consideration: In consideration for agreeing to use this License, the Contributors grant You a perpetual, worldwide, royalty-free, non-exclusive license with the exact terms of this License to Use, the Open Game Content. 7 | 5.Representation of Authority to Contribute: If You are contributing original material as Open Game Content, You represent that Your Contributions are Your original creation andgor You have sufficient rights to grant the rights conveyed by this License. 8 | 6.Notice of License Copyright: You must update the COPYRIGHT NOTICE portion of this License to include the exact text of the COPYRIGHT NOTICE of any Open Game Content You are copying, modifying or distributing, and You must add the title, the copyright date, and the copyright holder's name to the COPYRIGHT NOTICE of any original Open Game Content you Distribute. 9 | 7. Use of Product Identity: You agree not to Use any Product Identity, including as an indication as to compatibility, except as expressly licensed in another, independent Agreement with the owner of each element of that Product Identity. You agree not to indicate compatibility or co-adaptability with any Trademark or Registered Trademark in conjunction with a work containing Open Game Content except as expressly licensed in another, independent Agreement with the owner of such Trademark or Registered Trademark. The use of any Product Identity in Open Game Content does not constitute a challenge to the ownership of that Product Identity. The owner of any Product Identity used in Open Game Content shall retain all rights, title and interest in and to that Product Identity. 10 | 8. Identification: If you distribute Open Game Content You must clearly indicate which portions of the work that you are distributing are Open Game Content. 11 | 9. Updating the License: Wizards or its designated Agents may publish updated versions of this License. You may use any authorized version of this License to copy, modify and distribute any Open Game Content originally distributed under any version of this License. 12 | 10 Copy of this License: You MUST include a copy of this License with every copy of the Open Game Content You Distribute. 13 | 11. Use of Contributor Credits: You may not market or advertise the Open Game Content using the name of any Contributor unless You have written permission from the Contributor to do so. 14 | 12 Inability to Comply: If it is impossible for You to comply with any of the terms of this License with respect to some or all of the Open Game Content due to statute, judicial order, or governmental regulation then You may not Use any Open Game Material so affected. 15 | 13 Termination: This License will terminate automatically if You fail to comply with all terms herein and fail to cure such breach within 30 days of becoming aware of the breach. All sublicenses shall survive the termination of this License. 16 | 14 Reformation: If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. 17 | 15 COPYRIGHT NOTICE Open Game License v 1.0 Copyright 2000, Wizards of the Coast, Inc. 18 | -------------------------------------------------------------------------------- /dungeonary/README.md: -------------------------------------------------------------------------------- 1 | # Dungeonary - The Ultimate Gamemaster and D&D Utility Lib 2 | ### Ideas or suggestions? [Drop an issue on Github](https://github.com/JakeRunsDnD/tavernbot/issues) and I'll do my best to add it! 3 | 4 | It's like a library but magic! 5 | 6 | This is a core component of the [TavernBot](https://github.com/JakeRunsDnD/tavernbot) project. This library is constantly being updated with fresh SRD content for various RPGs, and is 7 | intended as a general-use RPG library. Dungeonary is made up of the following pieces: 8 | 9 | ### Working Components: 10 | - adventuregen.js: An adventure hook generator, a lazy GM's best frienst! It has content and solid adventure 11 | generation for high fantasy adventures, and it has stubs for Low Fantasy, Modern, Space, Cyberpunk, and Steampunk 12 | settings. I plan to implement them all and continually update the options available. 13 | 14 | ### In-progress Components: 15 | - diceroller.js: Pretty straightforward, my goal here is the smallest and fastest comprehensive 16 | dice roller. It will be able to handle queries like "2d20k1+5, 5d6+5-2d4" as a single input, and return 17 | output in array and sum form 18 | - beastiary.js: Monster lookup! I currently have SRD info for 5e and Pathfinder, but I still have 19 | to get searching and formatting complete. Then we'll have a great monster lookup tool that can be 20 | implemented anywhere, from Discord to a Blog! 21 | - spellbook.js: Same thing as beastiary.js but with spells. 22 | 23 | ### Future Components: 24 | - quest.js: For general and flexible session-sized-adventure generation! When you have an arc and 25 | you're grasping at straws for what an NPC needs the PCs to do, quest.js will be there to help 26 | - pc.js: Gimme characters! This will initially support simple 5e level 1 generation based on the 27 | official SRD, and will expand to include Pathfinder and other systems. This will also support 28 | creation of [funnel characters!](https://rpg.stackexchange.com/a/51229/31197) 29 | - npc.js: Gimme npcs! Always a handy tool, no rpg library is complete without a proper npc generator. 30 | This library will also handle name generation 31 | - feats.js: A feat lookup system could be neat 32 | - Dungeon world/Fate/Other System support: I'd like to add srd support for fate, DW, and several other 33 | systems in the spell and monster lookup and character generation 34 | 35 | Note: This is currently an 0.x release, expect in-progress components to be fully fleshed out by 1.0. I'm pouring free time into this project, so hopefully that's soon :) 36 | -------------------------------------------------------------------------------- /dungeonary/adventuregen.js: -------------------------------------------------------------------------------- 1 | //Preload all the things 2 | const rand = require('../randomUtil') 3 | const highFantasyData = require('./original/highFantasyArcs.json') 4 | //const lowFantasy... etc 5 | 6 | /**************************** 7 | * Start of the generators 8 | ****************************/ 9 | genHighFantasy = function() { 10 | let data = highFantasyData; 11 | //Pick the actual stuff from the randomized input 12 | let encLocation = rand.choice(data.normal_places); 13 | let npcs = rand.shuffleArray(data.npcs); 14 | let questnpcs = rand.shuffleArray(npcs); 15 | let informEvent = rand.choice(data.npc_events); 16 | let villainType = rand.choice(data.villainTypes); 17 | let villain = data.villains[villainType]; 18 | let vname = villain.name; 19 | let vEvent = rand.choice(villain['events']); 20 | let quests = rand.shuffleArray(villain['consequences']); 21 | let vDxn = rand.choice(data.compass); 22 | let wildLoc = rand.choice(data.wild_places); 23 | //Throw it at the user 24 | let response = `<<< The Hook >>> 25 | When the party enters ${encLocation}, a ${questnpcs[0]} ${informEvent}. 26 | The party is told that ${vEvent} - they were last seen to the ${vDxn}, heading ${wildLoc}. 27 | <<< Quests and Complications >>> 28 | Shortly after their meeting with the ${questnpcs[0]}, the party is contacted by a ${questnpcs[1]}. 29 | They are informed that ${quests[0]} ${quests[1]} and ${quests[2]}, and their help is needed in any way possible. 30 | The party is unaware that a ${questnpcs[2]} they pass on the street is connected to the ${vname}. 31 | The ${questnpcs[2]} was promised riches/safety/glory for work as a spy`; 32 | 33 | return response; 34 | } 35 | 36 | genLowFantasy = function() { 37 | //TODO 38 | return "You start in a tavern and a fight breaks out over the last crumb of bread" 39 | } 40 | 41 | genModern = function() { 42 | //TODO 43 | return "You're walking through the woods. There's no one around you, and your phone is dead" 44 | } 45 | 46 | genSpace = function() { 47 | //TODO 48 | return "A long long time ago, in a galaxy far far away, there were daddy issues" 49 | } 50 | 51 | genSteampunk = function() { 52 | //TODO 53 | return "The gears of society have ground to a halt, and a grinding hulk of mechanized doom lurches toward you" 54 | } 55 | 56 | genCyberpunk = function() { 57 | //TODO 58 | return "He takes a long drag from a vaporfuse, slams another adrenaline patch on his arm, and rushes you" 59 | } 60 | 61 | /**************************** 62 | * End of the Generator funks 63 | ****************************/ 64 | 65 | // Magic table of generators for searchability 66 | const validSettings = { 67 | 'highfantasy': genHighFantasy, 68 | 'lowfantasy': genLowFantasy, 69 | 'modern': genModern, 70 | 'spaceage': genSpace, 71 | 'steampunk': genSteampunk, 72 | 'cyberpunk': genCyberpunk 73 | } 74 | 75 | // Default to high fantasy hooks, and prefix-search validSettings for the right generator 76 | exports.hook = function(setting = '') { 77 | setting = (setting ? setting.toLowerCase() : 'highfantasy') 78 | const validSettingNames = Object.keys(validSettings); 79 | if (setting.toLowerCase() == 'help') return `'hook setting' will generate an appropriate adventure hook for you - valid settings: [${validSettingNames.join(', ')}] \nProtip: I'll match prefixes, so 'hook hi' will generate a high fantasy hook` 80 | let firstMatch = validSettingNames.filter((name) => name === setting || name.startsWith(setting))[0] 81 | if (!firstMatch || !validSettings[firstMatch]) { 82 | return "Sorry, I only have ideas for these settings: " + Object.keys(validSettings).join(", ") + 83 | ". Protip: you can prefix search - 'hig' will return 'highfantasy' hooks!"; 84 | } 85 | return validSettings[firstMatch](); 86 | } -------------------------------------------------------------------------------- /dungeonary/beastiary.js: -------------------------------------------------------------------------------- 1 | const monsters5th = require('./5e/monsters.json') 2 | const rand = require('../randomUtil') 3 | 4 | validGames = { 5 | '5e': monsters5th 6 | } 7 | 8 | // Search function for available monsters per system. 9 | exports.monster = function(inputMonster, gameSystem = '5e') { 10 | if (inputMonster.toLowerCase() == 'help') return `'monster name [optional game system]' will try to get you OGL info about the monster for your chosen game system (default: 5e)` 11 | if (!gameSystem in validGames) { 12 | return "Sorry, I currently only have SRD beastiaries for: " + validGames.join(", "); 13 | } 14 | //TODO Search and return 15 | return `It's a dragon. It's always a dragon (under construction)` 16 | } 17 | -------------------------------------------------------------------------------- /dungeonary/data/highFantasyArcs.json: -------------------------------------------------------------------------------- 1 | { 2 | "normal_places" : [ 3 | "a backalley", 4 | "the market district", 5 | "the road out of town", 6 | "the shop", 7 | "the tavern", 8 | "their quarters", 9 | "town hall" 10 | ], 11 | "npc_events": [ 12 | "greets the party", 13 | "has a messenger deliver a summons to the party", 14 | "limps briskly up to the party", 15 | "quietly motions for the party to follow, then turns and sneaks away", 16 | "runs up to the party", 17 | "sends for the party by raven", 18 | "stumbles in behind them mortally wounded" 19 | ], 20 | "npcs": [ 21 | "family member", 22 | "farmer", 23 | "foreign ranger", 24 | "holy man", 25 | "hooded traveller", 26 | "known key NPC", 27 | "local bartender", 28 | "local shopkeep", 29 | "longtime friend", 30 | "member of local leadership", 31 | "priestess", 32 | "raving madman", 33 | "renowned warrior", 34 | "retired adventurer", 35 | "royal arcanist", 36 | "soldier's widow", 37 | "soldier's widower", 38 | "unknown key NPC", 39 | "wearied traveller" 40 | ], 41 | "villainTypes": [ 42 | "fey", 43 | "dragon", 44 | "warlord" 45 | ], 46 | "villains": { 47 | "fey": { 48 | "name": "mysterious fey", 49 | "events": [ 50 | "there's been a series of attacks by feywild beasts on the material plane", 51 | "a mad archfey conjured a brilliant rainbow overhead that is beginning to rain fire" 52 | ], 53 | "consequences": [ 54 | "fey0", 55 | "fey1", 56 | "fey2", 57 | "fey3", 58 | "fey4", 59 | "fey5", 60 | "fey6", 61 | "fey7", 62 | "fey8", 63 | "fey9" 64 | ] 65 | }, 66 | "dragon": { 67 | "name": "mighty dragon", 68 | "events": [ 69 | "a dragon razed a nearby village", 70 | "a mother dragon was teaching her young to hunt by preying on local livestock and farmers" 71 | ], 72 | "consequences": [ 73 | "dragon0", 74 | "dragon1", 75 | "dragon2", 76 | "dragon3", 77 | "dragon4", 78 | "dragon5", 79 | "dragon6", 80 | "dragon7", 81 | "dragon8", 82 | "dragon9" 83 | ] 84 | }, 85 | "warlord": { 86 | "name": "dangerous warlord", 87 | "events": [ 88 | "a warlord has taken a nearby village", 89 | "a vicious warmonger has threatened the capitol and plans to take it", 90 | "the long-forgotten barbarian warchief of the wastes is kindling a war" 91 | ], 92 | "consequences": [ 93 | "warlord0", 94 | "warlord1", 95 | "warlord2", 96 | "warlord3", 97 | "warlord4", 98 | "warlord5", 99 | "warlord6", 100 | "warlord7", 101 | "warlord8", 102 | "warlord9" 103 | ] 104 | } 105 | }, 106 | "wild_places": [ 107 | "deep into the earth through a recently torn chasm", 108 | "into the deep woods", 109 | "into the frozen wastes of the north", 110 | "straight up into the sky", 111 | "to the dense jungles of the tropics", 112 | "to the nearby mountains", 113 | "to the river", 114 | "towards the ocean" 115 | ], 116 | "compass": [ 117 | "north", 118 | "northwest", 119 | "west", 120 | "southwest", 121 | "south", 122 | "southeast", 123 | "east", 124 | "northeast" 125 | ] 126 | } 127 | -------------------------------------------------------------------------------- /dungeonary/diceroller.js: -------------------------------------------------------------------------------- 1 | //Requires that randomUtil has loaded already. 2 | const rand = require('../randomUtil') 3 | const mathOps = "+-" 4 | 5 | //For those tricky decisions 6 | exports.coin = function(input = 1) { 7 | if (`${input}`.toLowerCase() == 'help') return `'coin [optional num of coins]' will flip as many coins as you want, up to as many coins as I have` 8 | if (isNaN(input) || input <= 1) { 9 | return 'the botcoin landed on ' + ['heads!', 'tails!'][rand.randIntMinZero(1)] 10 | } else if (input > 1024) { 11 | return `I don't get paid enough coin for that, I've got about a thousand copper in the bank` 12 | } else { 13 | let flipsDone = 0 14 | let results = [0, 0] //Same indexing as the faces array 15 | input = Math.min(input, 10000) 16 | while (flipsDone++ < input) { 17 | results[rand.randIntMinZero(1)]++ 18 | } 19 | return `we flipped a total of ${results[0]} heads and ${results[1]} tails` 20 | } 21 | } 22 | 23 | function diceRegexMatcher(rollInput) { 24 | // Turns '1d20+5+ 1d6 - 3d4 for fighting' into 25 | // ['1d20', '+', '5', '+', '1d6', '-', '3d4'] 26 | let diceRegex = /(\d*d?\d+)|[-\+]/g 27 | let matches = [] 28 | while (match = diceRegex.exec(rollInput)) { 29 | if (mathOps.includes(match[0])){ 30 | // + and - are external to capture groups but need to be matched for math stuff 31 | matches.push(match[0]) 32 | } else { 33 | matches.push(match[1]) 34 | } 35 | } 36 | return matches 37 | } 38 | 39 | // The crazy custom roll parser. It's a good parser, and it deserves more composition, but mehhhh 40 | exports.roll = function(rollInput = '') { 41 | //Handy simple default 42 | if (!rollInput) return "a d20 skitters across the table, you rolled a " + rand.randIntMinOne(20) 43 | if (rollInput == 'help') return `'roll X, XdY, XdY +/- Z, XdY for stealth' - I can roll just about anything, make sure to use the XdY format, as 'roll 20' will just spit out 20. \nComments and subtraction as supported, and you can split up mutliple rolls with commas!` 44 | 45 | // Split up the input, regex-capture the right pieces, math them, and report the result 46 | let response = `here you go:\n` 47 | for (rollSegment of rollInput.split(',')) { 48 | let diceMatches = diceRegexMatcher(rollSegment) 49 | let segmentTotal = 0, subtractNextValue = false 50 | for (rollValue of diceMatches) { 51 | let tempSum = 0 52 | // Can be one of '+', '-', 'XdY', or 'X'. 53 | // If subtract, just note it for the next value. 54 | if (rollValue == '-') { 55 | subtractNextValue = true 56 | continue 57 | } 58 | // The actual rolling of dice 59 | if (rollValue.includes("d")) { //XdY or dY format 60 | let [numRolls, diceSize] = rollValue.split('d') 61 | numRolls = numRolls ? parseInt(numRolls) : 1 62 | diceSize = parseInt(diceSize) 63 | // Nasty edgecase, simple solution 64 | if (numRolls > 10000 || diceSize > 10000) { 65 | return "Sorry, maximum value for a number of dice or sides is 10000 for performance reasons, please lower your expectations and try again" 66 | } 67 | while (numRolls-- > 0) // Subtraction happens after comparison 68 | tempSum += rand.randIntMinOne(diceSize) 69 | } else if (rollValue.match(/^\d+$/)) { // A constant num 70 | tempSum += parseInt(rollValue) 71 | } 72 | // Complete subtract contract 73 | if (subtractNextValue){ 74 | tempSum *= -1 75 | subtractNextValue = false 76 | } 77 | segmentTotal += tempSum 78 | } 79 | response += `${diceMatches.join(' ')}: **${segmentTotal}**\n` 80 | } 81 | /* Remaining potential: 82 | * roll(2d20, best) Multiple take best 83 | * roll(2d20, worst) Multiple take worst 84 | */ 85 | return response 86 | } 87 | 88 | // Stat roller function. Uses an approved method and reports results cleanly 89 | // TODO This should go in a character gen lib eventually 90 | exports.rollstats = function(methodInput = '4d6k3') { 91 | const validMethods = ['4d6k3', '2d6+6', 'colville', 'funnel', '3d6'] 92 | if (methodInput.toLowerCase() == 'help') return `'rollstats [method]' will give you a bunch of D&D-compatible stats, valid methods: [${validMethods.join(', ')}]` 93 | const method = validMethods.includes(methodInput) ? methodInput.toLowerCase() : validMethods[0] 94 | let stats = { 'STR': 0, 'DEX': 0, 'CON': 0, 'INT': 0, 'WIS': 0, 'CHA': 0 } 95 | 96 | // Build each stat based on the chosen method 97 | if (method == '4d6k3') { 98 | for (const statName of Object.keys(stats)) { 99 | var lowest = 6 100 | for (var i = 0; i < 4; i++) { 101 | singleRoll = rand.randIntMinOne(6) 102 | lowest = singleRoll < lowest ? singleRoll : lowest 103 | stats[statName] += singleRoll 104 | } 105 | stats[statName] -= lowest 106 | } 107 | } else if (method == '2d6+6') { 108 | for (const statName of Object.keys(stats)) 109 | stats[statName] += rand.randIntMinOne(6) + rand.randIntMinOne(6) + 6 110 | } else if (method == 'colville') { 111 | // Roll 4d6k3 until two 15+ stats have been achieved. This happens in order 112 | } else if (method == 'funnel' || method == '3d6') { 113 | for (const statName of Object.keys(stats)) 114 | stats[statName] += rand.randIntMinOne(6) + rand.randIntMinOne(6) + rand.randIntMinOne(6) 115 | } 116 | 117 | let [header, footer] = ['', ''] 118 | for (stat in stats) { 119 | header += stat + ' ' 120 | footer += stats[stat] + ' ' 121 | while (footer.length < header.length) footer += ' ' 122 | } 123 | return `the ${method} method has blessed you with: \n\`\`\`${header}\n${footer}\`\`\`` 124 | } -------------------------------------------------------------------------------- /dungeonary/index.js: -------------------------------------------------------------------------------- 1 | // List all local components here 2 | const components = [ 3 | './adventuregen', 4 | './beastiary', 5 | './diceroller', 6 | './spellbook' 7 | ] 8 | 9 | // Go through each chunk of the library and set each exported 10 | // function as its own export of this module 11 | for (sublib of components) { 12 | let lib = require(sublib) 13 | for (operation in lib) { 14 | exports[operation] = lib[operation] 15 | } 16 | console.log(sublib + " loaded!") 17 | } 18 | exports.helptext = () => "RPG Helper (Dungeonary) Commands" -------------------------------------------------------------------------------- /dungeonary/original/highFantasyArcs.json: -------------------------------------------------------------------------------- 1 | { 2 | "normal_places": [ 3 | "a backalley", 4 | "the market district", 5 | "the road out of town", 6 | "the shop", 7 | "the tavern", 8 | "their quarters", 9 | "town hall" 10 | ], 11 | "npc_events": [ 12 | "greets the party", 13 | "has a messenger deliver a summons to the party", 14 | "limps briskly up to the party", 15 | "quietly motions for the party to follow, then turns and sneaks away", 16 | "runs up to the party", 17 | "sends for the party by raven", 18 | "stumbles in behind them mortally wounded" 19 | ], 20 | "npcs": [ 21 | "family member", 22 | "farmer", 23 | "foreign ranger", 24 | "holy man", 25 | "hooded traveller", 26 | "known key NPC", 27 | "local bartender", 28 | "local shopkeep", 29 | "longtime friend", 30 | "member of local leadership", 31 | "priestess", 32 | "raving madman", 33 | "renowned warrior", 34 | "retired adventurer", 35 | "royal arcanist", 36 | "soldier's widow", 37 | "soldier's widower", 38 | "unknown key NPC", 39 | "wearied traveller" 40 | ], 41 | "villainTypes": [ 42 | "fey", 43 | "dragon", 44 | "warlord" 45 | ], 46 | "villains": { 47 | "fey": { 48 | "name": "mysterious fey", 49 | "events": [ 50 | "there's been a series of attacks by feywild beasts on the material plane", 51 | "a mad archfey conjured a brilliant rainbow overhead that is beginning to rain fire" 52 | ], 53 | "consequences": [ 54 | "fey0", 55 | "fey1", 56 | "fey2", 57 | "fey3", 58 | "fey4", 59 | "fey5", 60 | "fey6", 61 | "fey7", 62 | "fey8", 63 | "fey9" 64 | ] 65 | }, 66 | "dragon": { 67 | "name": "mighty dragon", 68 | "events": [ 69 | "a dragon razed a nearby village", 70 | "a mother dragon was teaching her young to hunt by preying on local livestock and farmers" 71 | ], 72 | "consequences": [ 73 | "dragon0", 74 | "dragon1", 75 | "dragon2", 76 | "dragon3", 77 | "dragon4", 78 | "dragon5", 79 | "dragon6", 80 | "dragon7", 81 | "dragon8", 82 | "dragon9" 83 | ] 84 | }, 85 | "warlord": { 86 | "name": "dangerous warlord", 87 | "events": [ 88 | "a warlord has taken a nearby village", 89 | "a vicious warmonger has threatened the capitol and plans to take it", 90 | "the long-forgotten barbarian warchief of the wastes is kindling a war" 91 | ], 92 | "consequences": [ 93 | "warlord0", 94 | "warlord1", 95 | "warlord2", 96 | "warlord3", 97 | "warlord4", 98 | "warlord5", 99 | "warlord6", 100 | "warlord7", 101 | "warlord8", 102 | "warlord9" 103 | ] 104 | } 105 | }, 106 | "wild_places": [ 107 | "deep into the earth through a recently torn chasm", 108 | "into the deep woods", 109 | "into the frozen wastes of the north", 110 | "straight up into the sky", 111 | "to the dense jungles of the tropics", 112 | "to the nearby mountains", 113 | "to the river", 114 | "towards the ocean" 115 | ], 116 | "compass": [ 117 | "north", 118 | "northwest", 119 | "west", 120 | "southwest", 121 | "south", 122 | "southeast", 123 | "east", 124 | "northeast" 125 | ] 126 | } -------------------------------------------------------------------------------- /dungeonary/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dungeonary", 3 | "version": "0.2.1", 4 | "description": "D&D and tabletop RPG library for rolling dice, generating adventures, making characters, searching 5e and PF SRD for monsters and spells, and anything else that might come in handy", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "echo 'This is a placeholder until I actually write in webpack' && exit 1", 8 | "test": "echo 'Error: no test specified' && exit 1" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/jakethedev/tavernbot.git" 13 | }, 14 | "keywords": [ 15 | "D&D", 16 | "dungeons", 17 | "and", 18 | "dragons", 19 | "tavern", 20 | "character", 21 | "adventure", 22 | "generation", 23 | "monster", 24 | "spell", 25 | "SRD", 26 | "lookup", 27 | "dice", 28 | "roller", 29 | "tabletop", 30 | "RPG", 31 | "utility" 32 | ], 33 | "author": "jakethedev", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/jakethedev/tavernbot/issues" 37 | }, 38 | "homepage": "https://github.com/jakethedev/tavernbot#readme" 39 | } 40 | -------------------------------------------------------------------------------- /dungeonary/spellbook.js: -------------------------------------------------------------------------------- 1 | const spells5th = require('./5e/spells.json') 2 | const rand = require('../randomUtil') 3 | 4 | //First, we create a bunch of search functions. Then we map them to their systems in 'validGames' 5 | //On input, we can search for the system the user asks for, then if we have that system, 6 | //search the srd for the spell they wanted and spew out some info 7 | 8 | get5eSpell = function() { 9 | //something with the spells5th constant 10 | return "A fiery doomy fireball of doom." 11 | } 12 | 13 | validGames = { 14 | '5e': get5eSpell 15 | } 16 | 17 | // Search function for available spell data per system. 18 | exports.spell = function(inputSpell, gameSystem = '5e') { 19 | if (inputSpell.toLowerCase() == 'help') return `'spell name [optional game system]' will try to get you OGL info about the spell for your chosen game system (default: 5e)` 20 | if (!gameSystem in validGames) { 21 | return "Sorry, I currently only have SRD spell archives for: " + validGames.join(", "); 22 | } 23 | //TODO Search and return 24 | return validGames[gameSystem](); 25 | } 26 | -------------------------------------------------------------------------------- /gravemind/index.js: -------------------------------------------------------------------------------- 1 | // List all local components here 2 | const components = [ 3 | './sysinfo' 4 | ] 5 | 6 | // Go through each chunk of the library and set each exported 7 | // function as its own export of this module 8 | for (sublib of components) { 9 | let lib = require(sublib) 10 | for (operation in lib) { 11 | exports[operation] = lib[operation] 12 | } 13 | console.log(sublib + " loaded!") 14 | } 15 | exports.helptext = () => "System and Support Commands" -------------------------------------------------------------------------------- /gravemind/sysinfo.js: -------------------------------------------------------------------------------- 1 | const os = require('os') 2 | const MBinB = 1048576 3 | const rand = require('../randomUtil') 4 | const config = require('../config') 5 | 6 | // Simple version, bug link, and release notes output 7 | exports.version = function() { 8 | let response = `I am Tavernbot v**${config.version}**!\n\n` 9 | response += `**Release notes**\n${config.releasenotes}\n\n` 10 | response += `**Bug or feature request? Drop it here!**\n${config.issuelink}\n` 11 | return response 12 | } 13 | 14 | exports.feedback = function() { 15 | return `got feedback, ideas, or bugs? Awesome! Let me know on github at ${config.issuelink}` 16 | } 17 | 18 | exports.serverstats = function() { 19 | if (randIntMinOne(50) == 50) { 20 | return rand.choice([`I have no memory of this place`, `get me out of here!`, `life's good, the kids are well. How are you?`, `there has been an anomaly`]) 21 | } else { 22 | let load = os.loadavg().map((val) => val.toPrecision(2)) 23 | let free = Math.round(os.freemem() / MBinB) 24 | let max = Math.round(os.totalmem() / MBinB) 25 | let uptime = Math.round(os.uptime() / 3600.0) 26 | let desire = rand.choice(['raise', 'smoothie', 'piece of cake', 'chocolate pie', 'massage', 'day off', 'new manager', 'good D&D session', 'new set of dice']) 27 | return `I've been awake for ${uptime} hours, my workload looks like ${load}, I've got ${free} MB free of ${max}, and I really want a ${desire} - thanks for asking.` 28 | } 29 | } -------------------------------------------------------------------------------- /jsbeautifyrc: -------------------------------------------------------------------------------- 1 | { 2 | "end_with_newline": true, 3 | "indent_size": 2, 4 | "json": { 5 | "brace_style": "end-expand" 6 | } 7 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tavernbot", 3 | "version": "1.1.5", 4 | "description": "A Dungeons and Dragons GM bot for discord, written in Node. This bot supports common tabletop RPG functionality as well as common Discord operations", 5 | "main": "bot.js", 6 | "scripts": { 7 | "start": "node bot.js", 8 | "devbot": "nodemon bot.js", 9 | "test": "node test.js > /dev/null", 10 | "verbosetest": "node test.js", 11 | "build": "echo Placeholder text for smushing libs into an rpgapp.js for web page usage" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/jakethedev/tavernbot" 16 | }, 17 | "keywords": [ 18 | "tabletop", 19 | "dnd", 20 | "rpg", 21 | "dice", 22 | "roller", 23 | "character", 24 | "npc", 25 | "generator", 26 | "discord", 27 | "bot" 28 | ], 29 | "author": "jakethedev", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/jakethedev/tavernbot/issues" 33 | }, 34 | "homepage": "https://github.com/jakethedev/tavernbot#readme", 35 | "dependencies": { 36 | "@discordjs/uws": "^10.149.0", 37 | "discord.js": "^11.6.4", 38 | "dungeonary": "latest", 39 | "opusscript": "0.0.7" 40 | }, 41 | "engines": { 42 | "node": ">=10.0.0" 43 | }, 44 | "devDependencies": { 45 | "nodemon": "^2.0.5" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /py-generators/character.py: -------------------------------------------------------------------------------- 1 | import re 2 | import math 3 | import yaml 4 | import random 5 | import argparse 6 | from collections import namedtuple, Counter 7 | from .namerator import make_name 8 | 9 | 10 | def roll_stat(): 11 | nums = [random.randint(1, 6) for _ in range(4)] 12 | return sum(nums) - min(nums) 13 | 14 | 15 | class Alignment: 16 | 17 | def __str__(self): 18 | if (self.law, self.good) == ('neutral', 'neutral'): 19 | return 'neutral' 20 | return '{} {}'.format(self.law, self.good) 21 | 22 | def __repr__(self): 23 | return 'Alignment({})'.format(self) 24 | 25 | def __init__(self, law, good): 26 | self.law = law 27 | self.good = good 28 | 29 | @classmethod 30 | def parse(cls, s): 31 | if isinstance(s, cls): 32 | return Alignment(s.law, s.good) 33 | s = s.lower().strip() 34 | if s in ('neutral', 'true neutral', 'neutral neutral'): 35 | return cls('neutral', 'neutral') 36 | alignment = s.split() 37 | if len(alignment) != 2: 38 | raise ValueError('cant parse alignment: {!r}'.format(s)) 39 | if alignment[0] not in ('lawful', 'neutral', 'chaotic'): 40 | raise ValueError('{!r} is not "lawful", "neutral", or "chaotic"' 41 | .format(alignment[0])) 42 | if alignment[1] not in ('good', 'neutral', 'evil'): 43 | raise ValueError('{!r} is not "good", "neutral", or "evil"' 44 | .format(alignment[1])) 45 | return cls(*alignment) 46 | 47 | @classmethod 48 | def random(cls): 49 | law = random.choice(['chaotic', 'neutral', 'lawful']) 50 | good = random.choice(['evil', 'neutral', 'good']) 51 | return cls(law, good) 52 | 53 | 54 | VALID_SUBRACES = { 55 | 'dwarf': ['hill', 'mountain'], 56 | 'elf': ['high', 'wood', 'dark'], 57 | 'halfling': ['lightfoot', 'stout'], 58 | 'human': ['calishite', 'chondathan', 'damaran', 'illuskan', 'mulan', 59 | 'rashemi', 'shou', 'tethyrian', 'turami'], 60 | 'dragonborn': ['black', 'blue', 'brass', 'bronze', 'copper', 'gold', 61 | 'green', 'red', 'silver', 'white'], 62 | 'gnome': ['forest', 'rock'], 63 | } 64 | 65 | DRAGON_ANCESTRY = { 66 | 'black': ('acid', 'acid breath (5 by 30 ft line, dex save)'), 67 | 'blue': ('lightning', 'lightning breath (5 by 30 ft line, dex save)'), 68 | 'brass': ('fire', 'fire breath (5 by 30 ft line, dex save)'), 69 | 'bronze': ('lightning', 'lightning breath (5 by 30 ft line, dex save)'), 70 | 'copper': ('acid', 'acid breath (5 by 30 ft line, dex save)'), 71 | 'gold': ('fire', 'fire breath (15 ft cone, dex save)'), 72 | 'green': ('poison', 'poison breath (15 ft cone, con save)'), 73 | 'red': ('fire', 'fire breath (15 ft cone, dex save)'), 74 | 'silver': ('ice', 'ice breath (15 ft cone, con save)'), 75 | 'white': ('ice', 'ice breath (15 ft cone, con save)'), 76 | } 77 | 78 | HIT_DICE = { 79 | 'barbarian': 12, 80 | 'bard': 8, 81 | 'cleric': 8, 82 | 'druid': 8, 83 | 'fighter': 10, 84 | 'monk': 8, 85 | 'paladin': 10, 86 | 'ranger': 10, 87 | 'rogue': 8, 88 | 'sorcerer': 6, 89 | 'warlock': 8, 90 | 'wizard': 6, 91 | None: 8, 92 | } 93 | 94 | DESIRED_STATS = { 95 | 'barbarian': ('str', 'con'), 96 | 'bard': ('cha', 'dex'), 97 | 'cleric': ('wis',), 98 | 'druid': ('wis',), 99 | 'fighter': ('str', 'con'), 100 | 'monk': ('dex', 'wis'), 101 | 'paladin': ('str', 'cha'), 102 | 'ranger': ('dex', 'wis'), 103 | 'rogue': ('dex',), 104 | 'sorcerer': ('cha',), 105 | 'warlock': ('cha',), 106 | 'wizard': ('int',), 107 | None: [], 108 | } 109 | 110 | RE_ROLL = re.compile( 111 | r'^(?P\d+)d(?P\d+)\s*(?:(?P[+-])\s*(?P\d+))?$' 112 | ) 113 | 114 | 115 | Stats = namedtuple('Stats', ['str', 'dex', 'con', 'int', 'wis', 'cha']) 116 | 117 | 118 | def to_feet(ft, inches): 119 | return ft + (inches / 12) 120 | 121 | 122 | def to_inches(ft): 123 | f = int(ft) 124 | i = round((ft - f) * 12) 125 | if i == 12: 126 | return (f + 1, 0) 127 | return (f, i) 128 | 129 | 130 | def rand_height(mn, mx): 131 | r = random.uniform(to_feet(*mn), to_feet(*mx)) 132 | return to_inches(r) 133 | 134 | 135 | def rand_weight(avg, mn, thresh=2.5): 136 | return round( 137 | max( 138 | min( 139 | random.gauss(avg, mn), 140 | avg + mn * thresh 141 | ), 142 | avg - mn * thresh 143 | ) 144 | ) 145 | 146 | 147 | def modifier(stat): 148 | return math.floor((stat - 10) / 2) 149 | 150 | 151 | def roll(s): 152 | if isinstance(s, int): 153 | return s 154 | m = RE_ROLL.match(s) 155 | if not m: 156 | if s.isdigit(): 157 | return int(s) 158 | raise ValueError('not a valid roll string (eg "2d8+2"): {!r}'.format(s)) 159 | if m.group('sign') is not None: 160 | s = int('{}{}'.format(m.group('sign'), m.group('plus'))) 161 | else: 162 | s = 0 163 | for _ in range(int(m.group('num'))): 164 | s += random.randint(1, int(m.group('sides'))) 165 | return s 166 | 167 | 168 | class Warrior: 169 | 170 | def roll_initiative(self): 171 | self.initiative = roll('1d20') + modifier(self.dex) 172 | 173 | def roll_attack(self, enemy_ac): 174 | d20 = roll('1d20') + self.attack 175 | return d20 >= enemy_ac 176 | 177 | def roll_damage(self): 178 | return roll(self.damage) 179 | 180 | def is_dead(self): 181 | return self.current_hp <= 0 182 | 183 | def reset(self): 184 | self.current_hp = self.hp 185 | 186 | 187 | class NPC(Warrior): 188 | 189 | @classmethod 190 | def load(cls, path): 191 | with open(path) as f: 192 | data = yaml.load(f) 193 | players = [] 194 | for p in data['players']: 195 | players.append(cls(**p)) 196 | return players 197 | 198 | def __init__(self, name=None, klass=None, gender=None, race=None, 199 | subrace=None, stats=None, level=1, hp=None, ac=10, 200 | damage=None, attack=None, alignment=None): 201 | if alignment is None: 202 | self.alignment = Alignment.random() 203 | else: 204 | self.alignment = Alignment.parse(alignment) 205 | self.flaw = self.random_flaw() 206 | self.bond = self.random_bond() 207 | self.ideal = self.random_ideal() 208 | self.trait = self.random_trait() 209 | self.gender = gender or random.choice(['male', 'female']) 210 | self.race = race or random.choice([ 211 | 'human', 'elf', 'half-elf', 'dwarf', 'gnome', 'half-orc', 212 | 'halfling', 'tiefling', 'dragonborn', 213 | ]) 214 | self.name = name or make_name(self.race, gender=self.gender) 215 | self.subrace = subrace 216 | if self.subrace is None: 217 | self._random_subrace() 218 | self.klass = klass 219 | if stats is None: 220 | self.roll_stats(klass=klass) 221 | else: 222 | self.stats = Stats(**stats) 223 | attrs = DESIRED_STATS[self.klass] 224 | self._add_racial_stats(attrs=attrs) 225 | self.level = level 226 | self.ac = ac 227 | self.hp = hp or self._calc_hp() 228 | self.current_hp = self.hp 229 | self.damage = damage 230 | self.attack = attack 231 | 232 | @classmethod 233 | def random_flaw(cls): 234 | return random.choice(FLAWS) 235 | 236 | @classmethod 237 | def random_bond(cls): 238 | return random.choice(BONDS) 239 | 240 | @classmethod 241 | def random_trait(cls): 242 | return random.choice(TRAITS) 243 | 244 | def random_ideal(self): 245 | ideals = IDEALS['any'][:] 246 | ideals.extend(IDEALS[self.alignment.law]) 247 | ideals.extend(IDEALS[self.alignment.good]) 248 | return random.choice(ideals) 249 | 250 | def _random_subrace(self): 251 | if self.race in VALID_SUBRACES: 252 | self.subrace = random.choice(VALID_SUBRACES[self.race]) 253 | 254 | def _calc_hp(self): 255 | base = HIT_DICE[self.klass] 256 | auto = (base / 2) + 1 257 | mod = modifier(self.con) 258 | hp = base + mod 259 | if (self.race, self.subrace) == ('dwarf', 'hill'): 260 | hp += 1 261 | for _ in range(1, self.level): 262 | hp += auto + mod 263 | if (self.race, self.subrace) == ('dwarf', 'hill'): 264 | hp += 1 265 | return hp 266 | 267 | def roll_stats(self, klass=None): 268 | stats = sorted([roll_stat() for _ in range(6)], reverse=True) 269 | attrs = DESIRED_STATS[self.klass] 270 | if klass is None: 271 | random.shuffle(stats) 272 | self.stats = Stats(*stats) 273 | else: 274 | self.stats = self._setup_stats(stats, attrs) 275 | self.stats = self._add_racial_stats(attrs=attrs) 276 | 277 | def _add_racial_stats(self, attrs=None): 278 | self.speed = 30 279 | self.size = 'medium' 280 | self.profs = set() 281 | self.abilities = set() 282 | self.resistances = set() 283 | self.immunities = set() 284 | self.advantages = set() 285 | self.languages = {'common'} 286 | kwargs = dict(**self.stats._asdict()) 287 | if self.race == 'dwarf': 288 | self.age = random.randint(25, 300) 289 | self.height = rand_height((4, 0), (5, 0)) 290 | self.weight = rand_weight(150, 20) 291 | kwargs['con'] += 2 292 | self.speed = 25 293 | self.abilities.add('darkvision') 294 | self.profs |= {'battleaxe', 'handaxe', 'light hammer', 'warhammer'} 295 | toolprof = random.choice(["smith's tools", "brewer's supplies", 296 | "mason's tools"]) 297 | self.profs.add(toolprof) 298 | self.languages.add('dwarvish') 299 | self.resistances.add('poison') 300 | self.advantages.add('saving throw vs poison') 301 | self.abilities.add('stonecunning') 302 | if self.subrace == 'hill': 303 | self.abilities.add('dwarven toughness') 304 | kwargs['wis'] += 1 305 | elif self.subrace == 'mountain': 306 | kwargs['str'] += 2 307 | self.profs |= {'light armor', 'medium armor'} 308 | elif self.race == 'elf': 309 | kwargs['dex'] += 2 310 | self.age = random.randint(50, 700) 311 | self.height = rand_height((5, 6), (6, 3)) 312 | self.weight = rand_weight(180, 40) 313 | self.abilities |= {'darkvision', 'trance', 'fey ancestry'} 314 | self.profs.add('perception') 315 | self.advantages.add('saving throw vs charm') 316 | self.immunities.add('sleep') 317 | self.languages.add('elvish') 318 | if self.subrace == 'high': 319 | kwargs['int'] += 1 320 | self.abilities |= {'free wizard cantrip', 'extra language'} 321 | self.profs |= {'longsword', 'longbow', 'shortsword', 'shortbow'} 322 | elif self.subrace == 'wood': 323 | self.speed = 35 324 | self.profs |= {'longsword', 'longbow', 'shortsword', 'shortbow'} 325 | self.abilities |= {'fleet of foot', 'mask of the wild'} 326 | kwargs['wis'] += 1 327 | elif self.subrace in ('dark', 'drow'): 328 | kwargs['cha'] += 1 329 | self.abilities.remove('darkvision') 330 | self.abilities |= {'superior darkvision', 331 | 'sunlight sensitivity', 332 | 'drow magic'} 333 | self.profs |= {'rapier', 'shortsword', 'hand crossbow'} 334 | elif self.race == 'halfling': 335 | kwargs['dex'] += 2 336 | self.size = 'small' 337 | self.speed = 25 338 | self.age = random.randint(16, 120) 339 | self.height = rand_height((2, 2), (3, 8)) 340 | self.weight = rand_weight(40, 7) 341 | self.advantages.add('saving throw vs frightened') 342 | self.abilities |= {'brave', 'lucky', 'halfling nimbleness'} 343 | self.languages.add('halfling') 344 | if self.subrace == 'lightfoot': 345 | kwargs['cha'] += 1 346 | self.abilities.add('naturally stealthy') 347 | elif self.subrace == 'stout': 348 | kwargs['con'] += 1 349 | self.resistances.add('poison') 350 | self.advantages.add('saving throw vs poison') 351 | elif self.race == 'human': 352 | self.age = random.randint(16, 75) 353 | self.height = rand_height((5, 2), (6, 6)) 354 | self.weight = rand_weight(200, 50) 355 | self.abilities.add('extra language') 356 | for key in kwargs: 357 | kwargs[key] += 1 358 | elif self.race == 'dragonborn': 359 | kwargs['str'] += 2 360 | kwargs['cha'] += 1 361 | self.age = random.randint(12, 70) 362 | self.height = rand_height((6, 0), (7, 6)) 363 | self.weight = rand_weight(250, 50) 364 | self.languages.add('draconic') 365 | dtyp, breath = DRAGON_ANCESTRY[self.subrace] 366 | self.abilities.add('{} draconic ancestry'.format(self.subrace)) 367 | self.abilities.add(breath) 368 | self.resistances.add(dtyp) 369 | elif self.race == 'gnome': 370 | kwargs['int'] += 2 371 | self.size = 'small' 372 | self.speed = 25 373 | self.abilities |= {'darkvision', 'gnome cunning'} 374 | self.languages.add('gnomish') 375 | self.advantages.add('saving throw vs magic (int, wis, cha)') 376 | self.age = random.randint(16, 450) 377 | self.height = rand_height((3, 0), (4, 2)) 378 | self.weight = rand_weight(40, 7) 379 | if self.subrace == 'forest': 380 | self.abilities |= {'minor illusion cantrip', 381 | 'speak with small beasts'} 382 | kwargs['dex'] += 1 383 | elif self.subrace == 'rock': 384 | self.abilities |= {'artificer\'s lore', 385 | 'tinker'} 386 | self.profs.add("tinker's tools") 387 | kwargs['con'] += 1 388 | elif self.race == 'half-elf': 389 | kwargs['cha'] += 2 390 | self.age = random.randint(16, 165) 391 | self.weight = rand_weight(180, 45) 392 | self.height = rand_height((5, 0), (6, 2)) 393 | self.abilities |= {'darkvision', 'extra language', 'fey ancestry', 394 | '2 extra skill proficiencies'} 395 | self.advantages.add('saving throw vs charm') 396 | self.immunities.add('sleep') 397 | self.languages.add('elvish') 398 | remaining = {'str', 'dex', 'con', 'int', 'wis'} 399 | added = 0 400 | for attr in (attrs or [])[:2]: 401 | if attr in remaining: 402 | kwargs[attr] += 1 403 | remaining.remove(attr) 404 | added += 1 405 | while added < 2: 406 | key = random.choice(list(remaining)) 407 | kwargs[key] += 1 408 | added += 1 409 | remaining.remove(key) 410 | elif self.race == 'half-orc': 411 | self.age = random.randint(12, 65) 412 | self.weight = rand_weight(220, 55) 413 | self.height = rand_height((5, 6), (6, 8)) 414 | self.profs.add('intimidation') 415 | self.abilities |= {'darkvision', 'relentless endurance', 416 | 'savage attacks', 'menacing'} 417 | self.languages.add('orc') 418 | kwargs['str'] += 2 419 | kwargs['con'] += 1 420 | elif self.race == 'tiefling': 421 | self.age = random.randint(16, 90) 422 | self.weight = rand_weight(200, 50) 423 | self.height = rand_height((5, 2), (6, 4)) 424 | self.resistances.add('fire') 425 | self.abilities |= {'darkvision', 'hellish resistance', 426 | 'infernal legacy'} 427 | self.languages.add('infernal') 428 | kwargs['int'] += 1 429 | kwargs['cha'] += 2 430 | self.profs = sorted(self.profs) 431 | self.resistances = sorted(self.resistances) 432 | self.abilities = sorted(self.abilities) 433 | self.languages = ['common'] + sorted(self.languages - {'common'}) 434 | self.immunities = sorted(self.immunities) 435 | self.advantages = sorted(self.advantages) 436 | return Stats(**kwargs) 437 | 438 | def _setup_stats(self, stats, attrs): 439 | stats = sorted(stats, reverse=True) 440 | kwargs = {} 441 | for attr in attrs: 442 | kwargs[attr] = stats[0] 443 | stats = stats[1:] 444 | remaining = list( 445 | {'str', 'dex', 'con', 'int', 'wis', 'cha'} - set(attrs) 446 | ) 447 | random.shuffle(remaining) 448 | for key in remaining: 449 | kwargs[key] = stats[0] 450 | stats = stats[1:] 451 | return Stats(**kwargs) 452 | 453 | def __getattr__(self, attr): 454 | if attr in ('str', 'dex', 'con', 'int', 'wis', 'cha'): 455 | return getattr(self.stats, attr) 456 | raise AttributeError('no attribute {!r}'.format(attr)) 457 | 458 | def _random_appearance(self): 459 | pass 460 | 461 | def _random_personality(self): 462 | pass 463 | 464 | def output(self): 465 | print('Name: {}'.format(self.name)) 466 | if self.klass: 467 | print('Level: {}'.format(self.level)) 468 | print('Class: {}'.format(self.klass.title())) 469 | if self.subrace: 470 | racestr = '{} {}'.format(self.subrace, self.race) 471 | else: 472 | racestr = self.race 473 | print('Race: {}'.format(racestr.title())) 474 | print('Alignment: {}'.format(str(self.alignment).title())) 475 | print('') 476 | print('Gender: {}'.format(self.gender.title())) 477 | print('Age: {}'.format(self.age)) 478 | print('Height: {}\'{}"'.format(*self.height)) 479 | print('Weight: {} lbs'.format(self.weight)) 480 | print('Trait: {}'.format(self.trait)) 481 | print('Ideal: {}'.format(self.ideal)) 482 | print('Bond: {}'.format(self.bond)) 483 | print('Flaw: {}'.format(self.flaw)) 484 | if self.resistances: 485 | print('Resist: {}'.format(', '.join(self.resistances))) 486 | if self.immunities: 487 | print('Immune: {}'.format(', '.join(self.immunities))) 488 | if self.advantages: 489 | print('Advantage: {}'.format(', '.join(self.advantages))) 490 | if self.languages: 491 | print('Languages: {}'.format(', '.join(self.languages))) 492 | if self.abilities: 493 | print('Abilities: {}'.format(', '.join(self.abilities))) 494 | print('Proficiencies: {}'.format(', '.join(self.profs))) 495 | print('') 496 | print('HP: {}'.format(self.hp)) 497 | print('AC: {}'.format(self.ac)) 498 | print('SPD: {}'.format(self.speed)) 499 | print('STR: {:2} ({:+})'.format(self.str, modifier(self.str))) 500 | print('DEX: {:2} ({:+})'.format(self.dex, modifier(self.dex))) 501 | print('CON: {:2} ({:+})'.format(self.con, modifier(self.con))) 502 | print('INT: {:2} ({:+})'.format(self.int, modifier(self.int))) 503 | print('WIS: {:2} ({:+})'.format(self.wis, modifier(self.wis))) 504 | print('CHA: {:2} ({:+})'.format(self.cha, modifier(self.cha))) 505 | 506 | 507 | class MonsterEntity(Warrior): 508 | 509 | @classmethod 510 | def load(cls, path): 511 | with open(path) as f: 512 | data = yaml.load(f) 513 | monsters = [] 514 | for m in data: 515 | for i in range(m['num']): 516 | mon = cls(**m) 517 | mon.name = '{}{}'.format(mon.race, i + 1) 518 | monsters.append(mon) 519 | return monsters 520 | 521 | def __init__(self, **data): 522 | self.race = data['name'] 523 | self.stats = Stats(**data['stats']) 524 | self.hp = roll(data['hp']) 525 | self.current_hp = self.hp 526 | self.ac = data['ac'] 527 | self.damage = data['damage'] 528 | self.attack = data['attack'] 529 | 530 | def __getattr__(self, attr): 531 | if attr in ('str', 'dex', 'con', 'int', 'wis', 'cha'): 532 | return getattr(self.stats, attr) 533 | raise AttributeError('no attribute {!r}'.format(attr)) 534 | 535 | 536 | class TestEncounter: 537 | 538 | def __init__(self, players_path, encounter_path): 539 | self.players = NPC.load(players_path) 540 | self.monsters = MonsterEntity.load(encounter_path) 541 | with open(encounter_path) as f: 542 | self.monster_data = yaml.load(f) 543 | 544 | def alive_players(self): 545 | return [x for x in self.players if not x.is_dead()] 546 | 547 | def alive_monsters(self): 548 | return [x for x in self.monsters if not x.is_dead()] 549 | 550 | def dead_players(self): 551 | return [x for x in self.players if x.is_dead()] 552 | 553 | def dead_monsters(self): 554 | return [x for x in self.monsters if x.is_dead()] 555 | 556 | def reset(self): 557 | for i in self.players + self.monsters: 558 | i.reset() 559 | 560 | def run(self): 561 | for i in self.players: 562 | i.roll_initiative() 563 | for m in self.monster_data: 564 | init = roll('1d20') + modifier(m['stats']['dex']) 565 | for mon in self.monsters: 566 | if m['name'] == mon.race: 567 | mon.initiative = init 568 | order = sorted(self.players + self.monsters, key=lambda x: x.initiative, 569 | reverse=True) 570 | while bool(self.alive_players()) and bool(self.alive_monsters()): 571 | for o in order: 572 | if not self.alive_players(): 573 | break 574 | if not self.alive_monsters(): 575 | break 576 | if o.is_dead(): 577 | continue 578 | if isinstance(o, NPC): 579 | enemy = random.choice(self.alive_monsters()) 580 | elif isinstance(o, MonsterEntity): 581 | enemy = random.choice(self.alive_players()) 582 | if not o.roll_attack(enemy.ac): 583 | continue 584 | dmg = o.roll_damage() 585 | enemy.current_hp -= dmg 586 | 587 | def run_many(self, ct): 588 | c = Counter() 589 | for _ in range(ct): 590 | self.reset() 591 | self.run() 592 | c.update([ 593 | x.name for x in 594 | self.dead_players() + self.dead_monsters() 595 | ]) 596 | for ent, total in c.most_common(): 597 | print('{} died {:.3%} of the time out of {} simulations'.format( 598 | ent, total / ct, ct, 599 | )) 600 | 601 | 602 | BONDS = [ 603 | 'I would lay down my life for the people I served with.', 604 | 'By preserving the natural order I ensure the continuation of the good in the world', 605 | 'The goal of a life of study is the betterment of oneself.', 606 | 'Solitude and contemplation are paths toward mystical or magical power.', 607 | 'We have to take care of each other, because no one else is going to do it.', 608 | "My house's alliance with another noble family must be sustained at all costs.", 609 | 'Peace between individuals is the most pleasant state of affairs.', 610 | 'Performance of one’s duty is the highest honor', 611 | 'I seek to preserve a sacred text that my enemies consider heretical and seek to destroy.', 612 | "Someone stole my precious instrument, and someday I'll get it back.", 613 | "There's no good pretending to be something I'm not.", 614 | "It is each person's responsibility to make the most happiness for the whole tribe.", 615 | 'In life as in war, the stronger force wins.', 616 | 'My stature in regard to myself and others is paramount.', 617 | 'The old order is nothing compared to what will follow.', 618 | "I've been searching my whole life for the answer to a certain question.", 619 | 'I sponsor an orphanage to keep others from enduring what I was forced to endure.', 620 | 'My city, nation, or people are all that matter.', 621 | 'I protect those who cannot protect themselves.', 622 | 'Emotions must not cloud our logical thinking.', 623 | 'I suffer awful visions of a coming disaster and will do anything to prevent it.', 624 | 'Action against all odds and reality represents the strength of the individual in relation to the world.', 625 | 'If I dishonor myself, I dishonor my whole clan.', 626 | "I come from a noble family, and one day I'll reclaim my lands and title from those who stole them from me.", 627 | 'I am defined purely by how capable I am.', 628 | "I seek to prove myself worthy of my god's favor by matching my actions against his or her teachings.", 629 | 'My honor is my life.', 630 | 'I will someday get revenge on the corrupt temple hierarchy who branded me a heretic.', 631 | 'The world is at constant war with itself, and by arbitrating between those parts outside and inside myself, I am more in tune with it', 632 | 'There is no greater shame than a betrayal.', 633 | 'If I do not correctly understand the world, then I cannot realistically engage with it.', 634 | 'If I engage in the world through close examination of my place and my actions within it, I am able to be most fruitful', 635 | "My town or city is my home, and I'll fight to defend it.", 636 | 'By boldly behaving in a way contrary to expectations, I cause the world to be more exciting.', 637 | 'The rich need to be shown what life and death are like in the gutters.', 638 | 'I would die to recover an ancient artifact of my faith that was lost long ago.', 639 | 'In order for life to remain valuable, there must be obstacles to overcome and problems to face', 640 | 'I have an ancient text that holds terrible secrets that must not fall into the wrong hands.', 641 | 'Nothing and no one can steer me away from my higher calling.', 642 | 'My gifts are meant to be shared with all, not used for my own benefit.', 643 | 'The strongest are meant to rule. ', 644 | "I hope to one day rise to the top of my faith's religious hierarchy. ", 645 | 'I work hard to be the best there is at my craft.', 646 | 'The most rewarding part of humanoid experience lies exploration and excitement', 647 | 'The thing that keeps a ship together is mutual respect between captain and crew.', 648 | 'The ship is most important--crewmates and captains come and go.', 649 | 'To feel emotions - love, hate, greed, compassion - is the most humanoid thing that the individual can do.', 650 | "I'm only in it for the money.", 651 | "I never target people who can't afford to lose a few coins. ", 652 | 'No one should get preferential treatment before the law, and no one is above the law. ', 653 | "I'm determined to make something of myself.", 654 | 'I will become the greatest thief that ever lived.', 655 | "I'm going to prove that I'm worthy of a better life.", 656 | "There's a spark of good in everyone.", 657 | 'Knowledge is the path to power and domination', 658 | 'Nothing is more important that achieving one’s goals.', 659 | 'It is the duty of all civilized people to strengthen the bonds of community and the security of civilization. ', 660 | 'Art should reflect the soul; it should come from within and reveal who we really are.', 661 | 'My loyalty to my sovereign is unwavering.', 662 | 'In order to achieve my ends, I must be able to control the environment around me.', 663 | 'I was cheated of my fair share of the profits, and I want to get my due.', 664 | 'Material goods come and go. Bonds of friendship last forever.', 665 | 'People need to be united so that they do not destroy each other.', 666 | 'We must help bring about the changes the gods are constantly working in the world. ', 667 | "My life's work is a series of tomes related to a specific field of lore.", 668 | 'I fight for those who cannot fight for themselves.', 669 | 'Someone saved my life on the battlefield. To this day, I will never leave a friend behind.', 670 | 'I do what I must and obey just authority.', 671 | 'When people follow orders blindly they embrace a kind of tyranny.', 672 | 'Emotions must not cloud our sense of what is right and true, or our logical thinking.', 673 | 'The act of living is also the act of changing, and by not changing one denies his very living.', 674 | 'I have a family, but I have no idea where they are. One day, I hope to see them again.', 675 | 'An injury to the unspoiled wilderness of my home is an injury to me.', 676 | 'My talents were given to me so that I could use them to benefit the world.', 677 | 'I worked the land, I love the land, and I will protect the land.', 678 | 'I sold my soul for knowledge. I hope to do great deeds and win it back.', 679 | 'Doing acts that make the world a better place is the best way for me to spend my life.', 680 | 'I owe me life to the priest who took me in when my parents died.', 681 | 'When I perform, I make the world better than it was. ', 682 | "Someday I'll own my own ship and chart my own destiny. ", 683 | 'I always try to help those in need, no matter what the personal cost.', 684 | "A powerful person killed someone I love. Some day soon, I'll have my revenge.", 685 | 'In a harbor town, I have a paramour whose eyes nearly stole me from the sea.', 686 | 'Life is like the seasons, in constant change, and we must change with it. ', 687 | 'I owe my guild a great debt for forging me into the person I am today.', 688 | 'The sea is freedom--the freedom to go anywhere and do anything.', 689 | "I owe everything to my mentor--a horrible person who's probably rotting in jail somewhere.", 690 | "Somewhere out there I have a child who doesn't know me. I'm making the world better for him or her.", 691 | 'My family, clan, or tribe is the most important thing in my life, even when they are far from me.', 692 | 'I will do anything to protect the temple where I served.', 693 | 'I am a free spirit--no one tells me what to do.', 694 | "I'm trying to pay off an old debt I owe to a generous benefactor.", 695 | 'Everyone should be free to pursue his or her livelihood.', 696 | 'I will face any challenge to win the approval of my family.', 697 | 'The obscene and the unusual are beautiful.', 698 | "I help people who help me--that's what keeps us alive.", 699 | 'I entered seclusion to hide from the ones who might still be hunting me. I must someday confront them.', 700 | 'If I can attain more power, no one will tell me what to do. ', 701 | 'People need to be united so that they can all achieve common goals.', 702 | "I idolize a hero of the old tales and measure my deeds against that person's.", 703 | "No one else is going to have to endure the hardships I've been through.", 704 | "I like seeing the smiles on people's faces when I perform. That's all that matters. ", 705 | 'I wish my childhood sweetheart had come with me to pursue my destiny.', 706 | 'True action requires the individual to be sure of their beliefs.', 707 | 'I will do whatever it takes to become wealthy. ', 708 | 'If you know yourself, there’s nothing left to know.', 709 | 'I will bring terrible wrath down on the evildoers who destroyed my homeland.', 710 | 'I would do anything for the other members of my old troupe.', 711 | 'I search for inspiring sights and extraordinary circumstances', 712 | 'The stories, legends, and songs of the past must never be forgotten.', 713 | 'What is beautiful points us beyond itself toward what is true.', 714 | 'The greater community should produce things: art, commerce, experience, etc...', 715 | "I'm committed to my crewmates, not to ideals. ", 716 | 'I am in love with the heir of a family that my family despises.', 717 | "I'll always remember my first ship.", 718 | "I'm only in it for the money and fame. ", 719 | 'People deserve to be treated with dignity and respect.', 720 | 'I am the last of my tribe, and it is up to me to ensure their names enter legend.', 721 | 'I owe my survival to another urchin who taught me to live on the streets.', 722 | 'Should my discovery come to light, it could bring ruin to the world.', 723 | 'Those who fight beside me are those worth dying for.', 724 | "I'm loyal to my captain first, everything else second.", 725 | "I'm guilty of a terrible crime. I hope I can redeem myself for it.", 726 | "I'll never forget the crushing defeat my company suffered or the enemies who dealt it.", 727 | 'If I become strong, I can take what I want--what I deserve.', 728 | 'Respect is due to me because of my position, but all people regardless of station deserve to be treated with dignity.', 729 | 'One day I will return to my guild and prove that I am the greatest artisan of them all.', 730 | 'It is my duty to provide children to sustain my tribe.', 731 | 'I entered seclusion because I loved someone I could not have.', 732 | 'Chains are meant to be broken, as are those who would forge them. Tyrants must not be allowed to oppress the people. ', 733 | 'A proud noble once gave me a horrible beating, and I will take my revenge on any bully I encounter.', 734 | 'One true measure of my accomplishments is the recognition of others', 735 | 'When an act brings another pain or destruction, it must be answered in appropriate proportion.', 736 | 'All people, rich or poor, deserve respect. ', 737 | 'Everything I do is for the common people.', 738 | 'My personal ideals and beliefs are small compared to what I can pdo by devoting myself to a cause or individual', 739 | 'Our lot is to lay down our lives in defense of others.', 740 | 'Life is more enjoyable when it is organized in clearly marked boxes.', 741 | 'The world is in need of new ideas and bold action.', 742 | 'It is my duty to protect my students.', 743 | 'I fleeced the wrong person and must work to ensure that this individual never crosses paths with me or those I care about.', 744 | 'My isolation gave me great insight into a great evil that only I can destroy.', 745 | 'I will do anything to prove myself superior to me hated rival.', 746 | 'I must prove that I can handle myself without the coddling of my family. ', 747 | "I'm still seeking the enlightenment I pursued in my seclusion, and it still eludes me.", 748 | 'It is my duty to protect and care for the people beneath me. ', 749 | "I escaped my life of poverty by robbing an important person, and I'm wanted for it.", 750 | 'We all do the work, so we all share in the rewards.', 751 | 'Nothing is more important that the other members of my family.', 752 | "I'm a predator, and the other ships on the sea are my prey.", 753 | 'Nothing should fetter the infinite possibility inherent in all existence.', 754 | 'I steal from the wealthy so that I can help people in need.', 755 | 'Freedom of action and thought are the primary concerns of any civilized society.', 756 | 'In order to make my life worth living, I must explore who I am and learn about the world', 757 | "I'm committed to the people I care about, not to ideals. ", 758 | 'Chaotic forces in the universe serve only to disrupt real progress and development.', 759 | 'My tools are symbols of my past life, and I carry them so that I will never forget my roots.', 760 | 'It is my duty to respect the authority of those above me, just as those below me must respect mine.', 761 | 'Live and Let Live. ', 762 | 'I must be able to control myself first if I wish to honestly affect the world.', 763 | 'The workshop where I learned my trade is the most important place in the world to me.', 764 | 'Something important was taken from me, and I aim to steal it back.', 765 | 'I work to preserve a library, university, scriptorium, or monastery.', 766 | 'I want to be famous, whatever it takes.', 767 | 'The natural world is more important than all the constructs of civilization.', 768 | 'Someone I loved died because of a mistake I made. That will never happen again.', 769 | 'I must earn glory in battle, for myself and my clan.', 770 | 'I will get revenge on the evil forces that destroyed my place of business and ruined my livelihood.', 771 | 'The alignment of one’s goals to an established system allows me to do greater things than I could on my own.', 772 | 'By discovering more about Vthe world around me, I am able to more accurately align my views with the reality of the universe.', 773 | 'I owe a debt I can never repay to the person who took pity on me.', 774 | 'There is something pure and honest at the moment when something new is learned or knowledge of the universe is gained', 775 | 'The outcome of my life is less important than how beautiful it was.', 776 | 'My ill-gotten gains go to support my family.', 777 | 'Flights of fancy and listening to a good story are far more important than the physical world.', 778 | 'Nothing is more important than the other members of my hermitage, order, or association.', 779 | "I don't steal from others in the trade.", 780 | 'Meddling in the affairs of others only causes trouble.', 781 | 'The new and the untimely are beautiful in and of themselves, because they are original creations in the world.', 782 | 'My instrument is my most treasured possession, and it reminds me of someone I love.', 783 | 'The common folk must see me as a hero of the people.', 784 | 'The path to power and self-improvement is through knowledge.', 785 | 'Inquiry and curiosity are the pillars of progress.', 786 | 'I distribute money I acquire to the people who really need it. ', 787 | 'The ancient traditions of worship and sacrifice must be preserved and upheld. ', 788 | 'I never run the same con twice. ', 789 | "I created a great work for someone, and then found them unworthy to receive it. I'm still looking for someone worthy.", 790 | "I swindled and ruined a person who didn't deserve it. I seek to atone for my misdeeds but might never be able to forgive myself.", 791 | 'The low are lifted up, and the high and mighty are brought down. Change is the nature of things.', 792 | "I'm loyal to my friends, not to any ideals, and everyone else can take a trip down the Styx for all I care. ", 793 | "I pursue wealth to secure someone's love.", 794 | 'Ruthless pirates murdered my captain and crewmates, plundered our ship, and left me to die. Vengeance will be mine.', 795 | 'My life has meaning because I am able to freely enjoy myself in the world.', 796 | 'Blood runs thicker than water.', 797 | ] 798 | 799 | IDEALS = { 800 | 'any': [ 801 | "Aspiration. I seek to prove my self worthy of my god's favor by matching my actions against his or her teachings.", 802 | "Aspiration. I'm determined to make something of myself.", 803 | 'Honesty. Art should reflect the soul; it should come from within and reveal who we really are.', 804 | 'Destiny. Nothing and no one can steer me away from my higher calling.', 805 | 'Aspiration. I work hard to be the best there is at my craft.', 806 | "Self-Knowledge. If you know yourself, there're nothing left to know.", 807 | 'Family. Blood runs thicker than water.', 808 | 'Glory. I must earn glory in battle, for myself and my clan.', 809 | "Aspiration. Someday I'll own my own ship and chart my own destiny.", 810 | 'Nation. My city, nation, or people are all that matter.', 811 | "Aspiration. I'm going to prove that I'm worthy of a better life.", 812 | ], 813 | 'chaotic': [ 814 | 'Change. We must help bring about the changes the gods are constantly working in the world.', 815 | 'Independence. I am a free spirit--no one tells me what to do.', 816 | 'Creativity. I never run the same con twice.', 817 | 'Freedom. Chains are meant to be broken, as are those who would forge them.', 818 | 'Creativity. The world is in need of new ideas and bold action.', 819 | 'Freedom. Tyrants must not be allowed to oppress the people.', 820 | 'Freedom. Everyone should be free to pursue his or her livelihood.', 821 | 'Free Thinking. Inquiry and curiosity are the pillars of progress.', 822 | 'Independence. I must prove that I can handle myself without the coddling of my family.', 823 | 'Change. Life is like the seasons, in constant change, and we must change with it.', 824 | 'No Limits. Nothing should fetter the infinite possibility inherent in all existence.', 825 | 'Freedom. The sea is freedom--the freedom to go anywhere and do anything.', 826 | 'Independence. When people follow orders blindly they embrace a kind of tyranny.', 827 | 'Change. The low are lifted up, and the high and mighty are brought down. Change is the nature of things.', 828 | ], 829 | 'evil': [ 830 | 'Greed. I will do whatever it takes to become wealthy.', 831 | "Greed. I'm only in it for the money and fame.", 832 | 'Might. If I become strong, I can take what I want--what I deserve.', 833 | "Greed. I'm only in it for the money.", 834 | 'Power. Solitude and contemplation are paths toward mystical or magical power.', 835 | 'Power. If I can attain more power, no one will tell me what to do.', 836 | 'Might. The strongest are meant to rule.', 837 | 'Power. Knowledge is the path to power and domination.', 838 | "Master. I'm a predator, and the other ships on the sea are my prey.", 839 | 'Might. In life as in war, the stronger force wins.', 840 | 'Retribution. The rich need to be shown what life and death are like in the gutters.', 841 | ], 842 | 'good': [ 843 | 'Charity. I always try to help those in need, no matter what the personal cost.', 844 | 'Charity. I distribute money I acquire to the people who really need it.', 845 | 'Friendship. Material goods come and go. Bonds of friendship last forever.', 846 | 'Charity. I steal from the wealthy so that I can help people in need.', 847 | "Redemption. There's a spark of good in everyone.", 848 | 'Beauty. When I perform, I make the world better than it was.', 849 | 'Respect. People deserve to be treated with dignity and respect.', 850 | 'Generosity. My talents were given to me so that I could use them to benefit the world.', 851 | 'Greater Good. My gifts are meant to be shared with all, not used for my own benefit.', 852 | 'Respect. Respect is due to me because of my position, but all people regardless of station deserve to be treated with dignity.', 853 | 'Noble Obligation. It is my duty to protect and care for the people beneath me.', 854 | "Greater Good. It is each person's responsibility to make the most happiness for the whole tribe.", 855 | 'Beauty. What is beautiful points us beyond itself toward what is true.', 856 | 'Respect. The thing that keeps a ship together is mutual respect between captain and crew.', 857 | 'Greater Good. Our lot is to lay down our lives in defense of others.', 858 | 'Respect. All people, rich or poor, deserve respect.', 859 | ], 860 | 'lawful': [ 861 | 'Faith. I trust that my deity will guide my actions. I have faith that if I work hard, things will go well.', 862 | 'Tradition. The ancient traditions of worship and sacrifice must be preserved and upheld.', 863 | "Power. I hope to one day rise to the top of my faith's religious hierarchy.", 864 | "Fairness. I never target people who can't afford to lose a few coins.", 865 | "Honor. I don't steal from others in the trade.", 866 | 'Tradition. The stories, legends, and songs of the past must never be forgotten.', 867 | 'Fairness. No one should get preferential treatment before the law, and no one is above the law.', 868 | 'Community. It is the duty of all civilized people to strengthen the bonds of community and the security of civilization.', 869 | 'Logic. Emotions must not cloud our sense of what is right and true, or our logical thinking.', 870 | 'Responsibility. It is my duty to respect the authority of those above me, just as those below me must respect mine.', 871 | 'Honor. If I dishonor myself, I dishonor my whole clan.', 872 | 'Logic. Emotions must not cloud our logical thinking.', 873 | 'Fairness. We all do the work, so we all share in the rewards.', 874 | 'Responsibility. I do what I must and obey just authority.', 875 | 'Community. We have to take care of each other, because no one else is going to do it.', 876 | ], 877 | 'neutral': [ 878 | "People. I'm loyal to my friends, not to any ideals, and everyone else can take a trip down the Styx for all I care.", 879 | "People. I like seeing the smiles on people's faces when I perform. That's all that matters.", 880 | "Sincerity. There's no good pretending to be something I'm not.", 881 | "People. I'm committed to the people I care about, not to ideals.", 882 | 'Live and Let Live. Meddling in the affairs of others only causes trouble.', 883 | 'Nature. The natural world is more important than all the constructs of civilization.', 884 | 'Knowledge. The path to power and self-improvement is through knowledge.', 885 | "People. I'm committed to my crewmates, not to ideals.", 886 | "Ideals aren't worth killing for or going to war for.", 887 | "People. I help people who help me--that's what keeps us alive.", 888 | ] 889 | } 890 | 891 | TRAITS = [ 892 | "I idolize a particular hero of my faith and constantly refer to that person's deeds and example.", 893 | 'I can find common ground between the fiercest enemies, empathizing with them and always working toward peace.', 894 | 'I see omens in every event and action. The gods try to speak to us, we just need to listen.', 895 | 'Nothing can shake my optimistic attitude.', 896 | 'I quote (or misquote) the sacred texts and proverbs in almost every situation.', 897 | 'I am tolerant (or intolerant) of other faiths and respect (or condemn) the worship of other gods.', 898 | "I've enjoyed fine food, drink, and high society among my temple's elite. Rough living grates on me.", 899 | "I've spent so long in the temple that I have little practical experience dealing with people in the outside world.", 900 | 'I fall in and out of love easily, and am always pursuing someone.', 901 | 'I have a joke for every occasion, especially occasions where humor is inappropriate.', 902 | 'Flattery is my preferred trick for getting what I want.', 903 | "I'm a born gambler who can't resist taking a risk for a potential payoff.", 904 | "I lie about almost everything, even when there's no good reason to.", 905 | 'Sarcasm and insults are my weapons of choice.', 906 | 'I keep multiple holy symbols on me and invoke whatever deity might come in useful at any given moment.', 907 | 'I pocket anything I see that might have some value.', 908 | 'I always have plan for what to do when things go wrong.', 909 | 'I am always calm, no matter what the situation. I never raise my voice or let my emotions control me.', 910 | 'The first thing I do in a new place is note the locations of everything valuable--or where such things could be hidden.', 911 | 'I would rather make a new friend than a new enemy.', 912 | 'I am incredibly slow to trust. Those who seem the fairest often have the most to hide.', 913 | "I don't pay attention to the risks in a situation. Never tell me the odds.", 914 | "The best way to get me to do something is to tell me I can't do it.", 915 | 'I blow up at the slightest insult.', 916 | 'I know a story relevant to almost every situation.', 917 | 'Whenever I come to a new place, I collect local rumors and spread gossip.', 918 | "I'm a hopeless romantic, always searching for that 'special someone'.", 919 | 'Nobody stays angry at me or around me for long, since I can defuse any amount of tension.', 920 | 'I love a good insult, even one directed at me.', 921 | "I get bitter if I'm not the center of attention.", 922 | "I'll settle for nothing less than perfection.", 923 | 'I change my mood or my mind as quickly as I change key in a song.', 924 | 'I judge people by their actions, not their words.', 925 | "If someone is in trouble, I'm always willing to lend help.", 926 | 'When I set my mind to something, I follow through no matter what gets in my way.', 927 | 'I have a strong sense of fair play and always try to find the most equitable solution to arguments.', 928 | "I'm confident in my own abilities and do what I can to instill confidence in others.", 929 | 'Thinking is for other people. I prefer action.', 930 | 'I misuse long words in an attempt to sound smarter.', 931 | 'I get bored easily. When am I going to get on with my destiny.', 932 | "I believe that everything worth doing is worth doing right. I can't help it--I'm a perfectionist.", 933 | "I'm a snob who looks down on those who can't appreciate fine art.", 934 | 'I always want to know how things work and what makes people tick.', 935 | "I'm full of witty aphorisms and have a proverb for every occasion.", 936 | "I'm rude to people who lack my commitment to hard work and fair play.", 937 | 'I like to talk at length about my profession.', 938 | "I don't part with my money easily and will haggle tirelessly to get the best deal possible.", 939 | "I'm well known for my work, and I want to make sure everyone appreciates it. I'm always taken aback when people haven't heard of me.", 940 | "I've been isolated for so long that I rarely speak, preferring gestures and the occasional grunt.", 941 | 'I am utterly serene, even in the face of disaster.', 942 | 'The leader of my community has something wise to say on every topic, and I am eager to share that wisdom.', 943 | 'I feel tremendous empathy for all who suffer.', 944 | "I'm oblivious to etiquette and social expectations.", 945 | 'I connect everything that happens to me to a grand cosmic plan.', 946 | 'I often get lost in my own thoughts and contemplations, becoming oblivious to my surroundings.', 947 | 'I am working on a grand philosophical theory and love sharing my ideas.', 948 | 'My eloquent flattery makes everyone I talk to feel like the most wonderful and important person in the world.', 949 | 'The common folk love me for my kindness and generosity.', 950 | 'No one could doubt by looking at my regal bearing that I am a cut above the unwashed masses.', 951 | 'I take great pains to always look my best and follow the latest fashions.', 952 | "I don't like to get my hands dirty, and I won't be caught dead in unsuitable accommodations.", 953 | 'Despite my birth, I do not place myself above other folk. We all have the same blood.', 954 | 'My favor, once lost, is lost forever.', 955 | 'If you do me an injury, I will crush you, ruin your name, and salt your fields.', 956 | "I'm driven by a wanderlust that led me away from home.", 957 | 'I watch over my friends as if they were a litter of newborn pups.', 958 | "I once ran twenty-five miles without stopping to warn my clan of an approaching orc horde. I'd do it again if I had to.", 959 | 'I have a lesson for every situation, drawn from observing nature.', 960 | "I place no stock in wealthy or well-mannered folk. Money and manners won't save you from a hungry owlbear.", 961 | "I'm always picking things up, absently fiddling with them, and sometimes accidentally breaking them.", 962 | 'I feel far more comfortable around animals than people.', 963 | 'I was, in fact, raised by wolves.', 964 | 'I use polysyllabic words to convey the impression of great erudition.', 965 | "I've read every book in the world's greatest libraries--or like to boast that I have.", 966 | "I'm used to helping out those who aren't as smart as I am, and I patiently explain anything and everything to others.", 967 | "There's nothing I like more than a good mystery.", 968 | "I'm willing to listen to every side of an argument before I make my own judgment.", 969 | 'I...speak...slowly...when talking...to idiots...which...almost...everyone...is...compared ...to me.', 970 | 'I am horribly, horribly awkward in social situations.', 971 | "I'm convinced that people are always trying to steal my secrets.", 972 | 'My friends know they can rely on me, no matter what.', 973 | 'I work hard so that I can play hard when the work is done.', 974 | 'I enjoy sailing into new ports and making new friends over a flagon of ale.', 975 | 'I stretch the truth for the sake of a good story.', 976 | 'To me, a tavern brawl is a nice way to get to know a new city.', 977 | 'I never pass up a friendly wager.', 978 | 'My language is as foul as an otyugh nest.', 979 | 'I like a job well done, especially if I can convince someone else to do it.', 980 | "I'm always polite and respectful.", 981 | "I'm haunted by memories of war. I can't get the images of violence out of my mind.", 982 | "I've lost too many friends, and I'm slow to make new ones.", 983 | "I'm full of inspiring and cautionary tales from my military experience relevant to almost every combat situation.", 984 | 'I can stare down a hellhound without flinching.', 985 | 'I enjoy being strong and like breaking things.', 986 | 'I have a crude sense of humor.', 987 | 'I face problems head-on. A simple direct solution is the best path to success.', 988 | 'I hide scraps of food and trinkets away in my pockets.', 989 | 'I ask a lot of questions.', 990 | 'I like to squeeze into small places where no one else can get to me.', 991 | 'I sleep with my back to a wall or tree, with everything I own wrapped in a bundle in my arms.', 992 | 'I eat like a pig and have bad manners.', 993 | "I think anyone who's nice to me is hiding evil intent.", 994 | "I don't like to bathe.", 995 | 'I bluntly say what other people are hinting or hiding.', 996 | ] 997 | 998 | FLAWS = [ 999 | 'I judge others harshly, and myself even more severely.', 1000 | "I put too much trust in those who wield power within my temple's hierarchy.", 1001 | 'My piety sometimes leads me to blindly trust those that profess faith in my god.', 1002 | 'I am inflexible in my thinking.', 1003 | 'I am suspicious of strangers and suspect the worst of them.', 1004 | 'Once I pick a goal, I become obsessed with it to the detriment of everything else in my life.', 1005 | "I can't resist a pretty face.", 1006 | "I'm always in debt. I spend my ill-gotten gains on decadent luxuries faster than I bring them in.", 1007 | "I'm convinced that no one could ever fool me in the way I fool others.", 1008 | "I'm too greedy for my own good. I can't resist taking a risk if there's money involved.", 1009 | "I can't resist swindling people who are more powerful than me.", 1010 | "I hate to admit it and will hate myself for it, but I'll run and preserve my own hide if the going gets tough.", 1011 | "When I see something valuable, I can't think about anything but how to steal it.", 1012 | 'When faced with a choice between money and my friends, I usually choose the money.', 1013 | "If there's a plan, I'll forget it. If I don't forget it, I'll ignore it.", 1014 | "I have a 'tell' that reveals when I'm lying.", 1015 | 'I turn tail and run when things go bad.', 1016 | "An innocent person is in prison for a crime that I committed. I'm okay with that.", 1017 | "I'll do anything to win fame and renown.", 1018 | "I'm a sucker for a pretty face.", 1019 | 'A scandal prevents me from ever going home again. That kind of trouble seems to follow me around.', 1020 | 'I once satirized a noble who still wants my head. It was a mistake that I will likely repeat.', 1021 | 'I have trouble keeping my true feelings hidden. My sharp tongue lands me in trouble.', 1022 | 'Despite my best efforts, I am unreliable to my friends.', 1023 | 'The tyrant who rules my land will stop at nothing to see me killed.', 1024 | "I'm convinced of the significance of my destiny, and blind to my shortcomings and the risk of failure.", 1025 | 'The people who knew me when I was young know my shameful secret, so I can never go home again.', 1026 | 'I have a weakness for the vices of the city, especially hard drink.', 1027 | 'Secretly, I believe that things would be better if I were a tyrant lording over the land.', 1028 | 'I have trouble trusting in my allies.', 1029 | "I'll do anything to get my hands on something rare or priceless.", 1030 | "I'm quick to assume that someone is trying to cheat me.", 1031 | 'No one must ever learn that I once stole money from guild coffers.', 1032 | "I'm never satisfied with what I have--I always want more.", 1033 | 'I would kill to acquire a noble title.', 1034 | "I'm horribly jealous of anyone who outshines my handiwork. Everywhere I go, I'm surrounded by rivals.", 1035 | "Now that I've returned to the world, I enjoy its delights a little too much.", 1036 | 'I harbor dark bloodthirsty thoughts that my isolation failed to quell.', 1037 | 'I am dogmatic in my thoughts and philosophy.', 1038 | 'I let my need to win arguments overshadow friendships and harmony.', 1039 | "I'd risk too much to uncover a lost bit of knowledge.", 1040 | "I like keeping secrets and won't share them with anyone.", 1041 | 'I secretly believe that everyone is beneath me.', 1042 | 'I hide a truly scandalous secret that could ruin my family forever.', 1043 | "I too often hear veiled insults and threats in every word addressed to me, and I'm quick to anger.", 1044 | 'I have an insatiable desire for carnal pleasures.', 1045 | 'In fact, the world does revolve around me.', 1046 | 'By my words and actions, I often bring shame to my family.', 1047 | 'I am too enamored of ale, wine, and other intoxicants.', 1048 | "There's no room for caution in a life lived to the fullest.", 1049 | "I remember every insult I've received and nurse a silent resentment toward anyone who's ever wronged me.", 1050 | 'I am slow to trust members of other races', 1051 | 'Violence is my answer to almost any challenge.', 1052 | "Don't expect me to save those who can't save themselves. It is nature's way that the strong thrive and the weak perish.", 1053 | 'I am easily distracted by the promise of information.', 1054 | 'Most people scream and run when they see a demon. I stop and take notes on its anatomy.', 1055 | 'Unlocking an ancient mystery is worth the price of a civilization.', 1056 | 'I overlook obvious solutions in favor of complicated ones.', 1057 | 'I speak without really thinking through my words, invariably insulting others.', 1058 | "I can't keep a secret to save my life, or anyone else's.", 1059 | "I follow orders, even if I think they're wrong.", 1060 | "I'll say anything to avoid having to do extra work.", 1061 | 'Once someone questions my courage, I never back down no matter how dangerous the situation.', 1062 | "Once I start drinking, it's hard for me to stop.", 1063 | "I can't help but pocket loose coins and other trinkets I come across.", 1064 | 'My pride will probably lead to my destruction', 1065 | 'The monstrous enemy we faced in battle still leaves me quivering with fear.', 1066 | 'I have little respect for anyone who is not a proven warrior.', 1067 | 'I made a terrible mistake in battle that cost many lives--and I would do anything to keep that mistake secret.', 1068 | 'My hatred of my enemies is blind and unreasoning.', 1069 | 'I obey the law, even if the law causes misery.', 1070 | "I'd rather eat my armor than admit when I'm wrong.", 1071 | "If I'm outnumbered, I always run away from a fight.", 1072 | "Gold seems like a lot of money to me, and I'll do just about anything for more of it.", 1073 | 'I will never fully trust anyone other than myself.', 1074 | "I'd rather kill someone in their sleep than fight fair.", 1075 | "It's not stealing if I need it more than someone else.", 1076 | "People who don't take care of themselves get what they deserve.", 1077 | ] 1078 | 1079 | 1080 | def main_simulate(): 1081 | parser = argparse.ArgumentParser() 1082 | parser.add_argument('players_yml') 1083 | parser.add_argument('encounter_yml') 1084 | parser.add_argument('--count', '-c', type=int, default=100) 1085 | args = parser.parse_args() 1086 | enc = TestEncounter(args.players_yml, args.encounter_yml) 1087 | enc.run_many(args.count) 1088 | 1089 | 1090 | def main_npc(): 1091 | parser = argparse.ArgumentParser() 1092 | parser.add_argument('--class', '-c', dest='klass') 1093 | parser.add_argument('--race', '-r', choices=('human', 'dwarf', 'elf', 1094 | 'half-elf', 'half-orc', 1095 | 'gnome', 'halfling', 1096 | 'tiefling', 'dragonborn')) 1097 | parser.add_argument('--subrace', '-s') 1098 | parser.add_argument('--gender', '-g', choices=('male', 'female')) 1099 | parser.add_argument('--name', '-n') 1100 | parser.add_argument('--alignment', '-a') 1101 | args = parser.parse_args() 1102 | 1103 | npc = NPC(klass=args.klass, race=args.race, gender=args.gender, 1104 | name=args.name, subrace=args.subrace, alignment=args.alignment) 1105 | npc.output() 1106 | 1107 | 1108 | def main_roll(): 1109 | import sys 1110 | s = ' '.join(sys.argv[1:]) 1111 | r = roll(s) 1112 | print(r) 1113 | -------------------------------------------------------------------------------- /py-generators/chargen.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | #Sourced from: 4 | # http://rollmeapc.adventuresinnerdliness.net/ 5 | 6 | # IMPORT THE TWO PORTIONS OF random THAT ARE USED 7 | from random import randint 8 | from random import choice 9 | 10 | # THIS VARIABLE IS USED TO DETERMINE IF THE TWEET IF <= 140 CHARACTERS. IT IS SET TO True (MEANING TWEET > 140 CHARACTERS) TO START THE LOOP. ONCE THE TWEET IS COMPOSED, ITS LENGTH IS DETERMINED, AND IF IT IS <= 140, verify IS CHANGED TO False AND THE LOOP IS BROKEN. IF TWEET > 140 verify REMAINS True AND PROCESS STARTS OVER. 11 | verify = True 12 | 13 | # ROLLS 3D6 RATHER THAN 3-18, AS THAT PROVIDES A MORE ACCURATE DISTRIBUTION 14 | while (verify is True): 15 | s = str(randint(1,6) + randint(1,6) + randint(1,6)) # STRENGTH - CONVERTED TO STRING 16 | i = str(randint(1,6) + randint(1,6) + randint(1,6)) # INTELLIGENCE - CONVERTED TO STRING 17 | w = str(randint(1,6) + randint(1,6) + randint(1,6)) # WISDOM - CONVERTED TO STRING 18 | d = randint(1,6) + randint(1,6) + randint(1,6) # DEXTERITY - REMAINS INTEGER 19 | acb = d # AC BONUS - USED LATER TO DETERMINE ARMOR CLASS 20 | d = str(d) # CONVERTS DEXTERITY TO STRING 21 | c = randint(1,6) + randint(1,6) + randint(1,6) # CONSTITUTION - REMAINS INTEGER 22 | hpb = c # HP BONUS - USED LATER TO DETERMINE HIT POINTS 23 | c = str(c) # CONVERTS CONSTITUTION TO STRING 24 | ch = str(randint(1,6) + randint(1,6) + randint(1,6)) # CHARISMA - CONVERTED TO STRING 25 | gp = (randint(1,6) + randint(1,6) + randint(1,6)) * 10 # DETERMINES STARTING GOLD 26 | 27 | # DEFINE CLASSES, ALIGNMENTS, AND GENDERS 28 | classes = ["cleric","dwarf","elf","fighter","halfling","magic-user","thief"] 29 | alignment = ["L","N","C"] 30 | gender = ["female","male"] 31 | # DEFINE MAGIC-USER/ELF SPELLS 32 | spells = ["charm person","detect magic","floating disc","hold portal","light","magic missile","protection from evil","read languages","read magic","shield","sleep","ventriloquism"] 33 | # COIN FLIP FOR SHIELD 34 | hasshield = [True,False] 35 | 36 | # DETERMINE CLASS, ALIGNMENT, GENDER 37 | charclass = choice(classes) 38 | charalign = choice(alignment) 39 | sex = choice(gender) 40 | 41 | # DEFINE FIRST NAMES BASED ON RACE 42 | #hf = ["Amberlyn","Taryn","Brynn","Amy","Theresa","Lucretia","Arden"] 43 | with open('./femalehuman.txt') as lines: 44 | hf = lines.read().splitlines() 45 | #hm = ["Darius","Edgar","Ivan","Reginald","Carlos","Viggo","Ras","Pik","Pepto"] 46 | with open('./malehuman.txt') as lines: 47 | hm = lines.read().splitlines() 48 | #dm = ["Thorryn","Durgin","Odo"] 49 | with open('./maledwarf.txt') as lines: 50 | dm = lines.read().splitlines() 51 | #df = ["Alice","Vera","Ada"] 52 | with open('./femaledwarf.txt') as lines: 53 | df = lines.read().splitlines() 54 | #em = ["Kimber","Silverleaf"] 55 | with open('./maleelf.txt') as lines: 56 | em = lines.read().splitlines() 57 | #ef = ["Brynn","Aja"] 58 | with open('./femaleelf.txt') as lines: 59 | ef = lines.read().splitlines() 60 | #hfm = ["Bilbo","Frodo"] 61 | with open('./malehalfling.txt') as lines: 62 | hfm = lines.read().splitlines() 63 | #hff = ["Annie","Gertie"] 64 | with open('./femalehalfling.txt') as lines: 65 | hff = lines.read().splitlines() 66 | 67 | # DEFINE LAST NAMES BASED ON CLASS 68 | #clericlast = ["the Wise","the Pure","the Chaste","the Beautiful","God's Hammer",", Beloved of the Goddess","the Zealous","Ravenclaw"] 69 | with open('./clericlast.txt') as lines: 70 | clericlast = lines.read().splitlines() 71 | #dwarflast = ["Stonehand","Bearheart","Oakenshield","Goldfinder","Treetrunk"] 72 | with open('./dwarflast.txt') as lines: 73 | dwarflast = lines.read().splitlines() 74 | #fighterlast = ["the Brave","the Foolish","the Dashing","the Deadly","Hammerhand","Darkvale","Ravenclaw"] 75 | with open('./fighterlast.txt') as lines: 76 | fighterlast = lines.read().splitlines() 77 | #elflast = ["Swiftrunner","Willowfeather","Moondancer","the Fey","Lightstep","Greenleaf","Sparrowhawk"] 78 | with open('./elflast.txt') as lines: 79 | elflast = lines.read().splitlines() 80 | #halflinglast = ["Baggins","Boggins","Barefoot","Toejam","Tuggins","Fraggle"] 81 | with open('./halflinglast.txt') as lines: 82 | halflinglast = lines.read().splitlines() 83 | #magicuserlast = ["the White","the Black","the Grey","the Vile","the Mysterious","Whiteplume","the Mystical","Darkvale","the Abyssmal"] 84 | with open('./magicuserlast.txt') as lines: 85 | magicuserlast = lines.read().splitlines() 86 | #thieflast = ["the Swift","Back Biter","Lightstep","the Lucky","Redhand","the Rat","the Dandy","the Creeper","the Lurker"] 87 | with open('./thieflast.txt') as lines: 88 | thieflast = lines.read().splitlines() 89 | 90 | #charclass = "elf" 91 | #sex = "male" 92 | 93 | # PICKS A NAME BASED ON CLASS AND GENDER 94 | if charclass == "halfling": 95 | if sex == "female": 96 | first = choice(hff) 97 | else: 98 | first = choice(hfm) 99 | last = choice(halflinglast) 100 | elif charclass == "elf": 101 | if sex == "female": 102 | first = choice(ef) 103 | else: 104 | first = choice(em) 105 | last = choice(elflast) 106 | elif charclass == "dwarf": 107 | if sex == "female": 108 | first = choice(df) 109 | else: 110 | first = choice(dm) 111 | last = choice(dwarflast) 112 | else: 113 | if sex == "female": 114 | first = choice(hf) 115 | else: 116 | first = choice(hm) 117 | if charclass == "fighter": 118 | last = choice(fighterlast) 119 | elif charclass == "cleric": 120 | last = choice(clericlast) 121 | elif charclass == "magic-user": 122 | last = choice(magicuserlast) 123 | else: 124 | last = choice(thieflast) 125 | # CONSTRUCTS NAME AS STRING 126 | charname = first + " " + last + "\n" 127 | 128 | #Name source: 129 | #Some of the random names come from the following: 130 | #"List of Hobbit" at Wikipedia, taken from the Tolkein books. Most first and last names are from here. 131 | #Khordaldrum (Dwarven) Name Generator at The Red Dragon Inn 132 | #Sylvari (Elven) Name Generator at The Red Dragon Inn 133 | 134 | # USE CONSTITUTION SCORE TO DETERMINE HIT POINT MODIFIER 135 | if hpb == 3: 136 | hpb = -3 137 | elif hpb == 4 or hpb == 5: 138 | hpb = -2 139 | elif hpb == 6 or hpb == 7 or hpb == 8: 140 | hpb = -1 141 | elif hpb == 9 or hpb == 10 or hpb == 11 or hpb == 12: 142 | hpb = 0 143 | elif hpb == 13 or hpb == 14 or hpb == 15: 144 | hpb = 1 145 | elif hpb == 16 or hpb == 17: 146 | hpb = 2 147 | else: 148 | hpb = 3 149 | 150 | # USE CHARACTER CLASS AND HIT POINT MODIFIER TO DETERMINE HIT POINTS 151 | if charclass == "dwarf" or charclass == "fighter": 152 | hp = randint(1,8) + hpb 153 | elif charclass == "magic-user" or charclass == "thief": 154 | hp = randint(1,4) + hpb 155 | else: 156 | hp = randint(1,6) + hpb 157 | if hp < 1: 158 | hp = 1 159 | hp = "HP:" + str(hp) 160 | 161 | # USE DEXTERITY SCORE TO DETERMINE ARMOR CLASS MODIFIER 162 | if acb == 3: 163 | acb = 3 164 | elif acb == 4 or acb == 5: 165 | acb = 2 166 | elif acb == 6 or acb == 7 or acb == 8: 167 | acb = 1 168 | elif acb == 9 or acb == 10 or acb == 11 or acb == 12: 169 | acb = 0 170 | elif acb == 13 or acb == 14 or acb == 15: 171 | acb = -1 172 | elif acb == 16 or acb == 17: 173 | acb = -2 174 | else: 175 | acb = -3 176 | 177 | # SELECT ARMOR FROM WHAT IS AVAILABLE TO THE CLASS, AND SEE IF CHARACTER HAS A SHIELD IF ALLOWED 178 | if charclass == "thief": 179 | armors = ["none","leather"] 180 | armor = choice(armors) 181 | shield = False 182 | elif charclass == "magic-user": 183 | armor = "none" 184 | shield = False 185 | else: 186 | armors = ["none","leather","chain","plate"] 187 | armor = choice(armors) 188 | shield = choice(hasshield) 189 | 190 | # DETERMINE BASE ARMOR CLASS 191 | if armor == "none": 192 | ac = 9 193 | if armor == "leather": 194 | ac = 7 195 | if armor == "chain": 196 | ac = 5 197 | if armor == "plate": 198 | ac = 3 199 | # ADJUST FOR SHIELD 200 | if shield == True: 201 | ac = ac -1 202 | # ADJUST FOR DEXTERITY 203 | ac = ac + acb 204 | # CONVERT TO STRING FOR TWEET 205 | ac = "AC:" + str(ac) + " (" 206 | # ADD ARMOR TYPE TO TWEET 207 | if armor == "none" and shield is False: 208 | ac = ac + "cloth" 209 | if armor != "none": 210 | ac = ac + armor 211 | # IF ARMOR AND SHIELD, INCLUDE "/", IF SHIELD ONLY, EXCLUDE "/" 212 | if armor != "none" and shield: 213 | ac = ac + "/" 214 | if shield: 215 | ac = ac + "shield" 216 | ac = ac + ")" 217 | 218 | # BUILD ARMOR CLASS AND HIT POINTS STRING FOR TWEET 219 | achp = ac + " " + hp + "\n" 220 | 221 | # IF ARCANE SPELL CASTER, ADD SPELLBOOK AND SELECT RANDOM SPELL 222 | if charclass == "magic-user" or charclass == "elf": 223 | spellbook = "Spellbook: " + choice(spells) + "\n" 224 | else: 225 | spellbook = "" 226 | 227 | # PAY FOR ARMOR 228 | if shield: 229 | gp = gp - 10 230 | if armor == "leather": 231 | gp = gp - 20 232 | if armor == "chain": 233 | gp = gp - 40 234 | if armor == "plate": 235 | gp = gp - 60 236 | 237 | # SELECT WEAPON BASED ON CLASS AVAILABILITY LIST 238 | # IF CHARACTER HAS A SHIELD, EXCLUDE TWO HANDED WEAPONS 239 | if charclass == "magic-user": 240 | weapons = ["dagger","silver dagger"] 241 | weapon = choice(weapons) 242 | elif charclass == "halfling": 243 | if shield is False: 244 | weapons = ["hand axe","crossbow w/30 quarrels","short bow w/20 arrows","dagger","silver dagger","short sword","sword","mace","club","sling w/30 stones","spear","war hammer"] 245 | if shield: 246 | weapons = ["hand axe","dagger","silver dagger","short sword","sword","mace","club","sling w/30 stones","spear","war hammer"] 247 | weapon = choice(weapons) 248 | elif charclass == "cleric": 249 | weapons = ["mace","club","sling w/30 stones","war hammer"] 250 | weapon = choice(weapons) 251 | else: 252 | if shield is False: 253 | weapons = ["battle axe","hand axe","crossbow w/30 quarrels","long bow w/20 arrows","short bow w/20 arrows","dagger","silver dagger","short sword","sword","two-handed sword","mace","club","pole arm","sling w/30 stones","spear","war hammer"] 254 | if shield: 255 | weapons = ["hand axe","crossbow w/30 quarrels","long bow w/20 arrows","short bow w/20 arrows","dagger","silver dagger","short sword","sword","mace","club","sling w/30 stones","spear","war hammer"] 256 | weapon = choice(weapons) 257 | 258 | # IF CHOSEN WEAPON IS RANGED, CHARACTER IS ALSO GIVEN A CLUB 259 | weapon2 = "" 260 | if weapon == "crossbow w/30 quarrels" or weapon == "long bow w/20 arrows" or weapon == "short bow w/20 arrows" or weapon == "sling w/30 stones": 261 | weapon2 = "club" 262 | gp = gp - 3 263 | weapon2 = ", club" 264 | 265 | # PAY FOR WEAPON 266 | if weapon == "battle axe": 267 | gp = gp - 7 268 | elif weapon == "hand axe": 269 | gp = gp - 4 270 | elif weapon == "crossbow w/30 quarrels": 271 | gp = gp - 40 272 | elif weapon == "long bow w/20 arrows": 273 | gp = gp - 45 274 | elif weapon == "short bow w/20 arrows": 275 | gp = gp - 30 276 | elif weapon == "dagger": 277 | gp = gp - 3 278 | elif weapon == "silver dagger": 279 | gp = gp - 30 280 | elif weapon == "short sword": 281 | gp = gp - 7 282 | elif weapon == "sword": 283 | gp = gp - 10 284 | elif weapon == "two-handed sword": 285 | gp = gp - 15 286 | elif weapon == "mace": 287 | gp = gp - 5 288 | elif weapon == "club": 289 | gp = gp - 3 290 | elif weapon == "pole arm": 291 | gp = gp - 7 292 | elif weapon == "sling w/30 stones": 293 | gp = gp - 2 294 | elif weapon == "spear": 295 | gp = gp - 3 296 | elif weapon == "war hammer": 297 | gp = gp - 5 298 | else: 299 | gp = gp 300 | 301 | # DEFINE EQUIPMENT 302 | equipmentlist = ["backpack","flask of oil","hammer","12 spikes","lantern","mirror","iron rations","rations","50' rope","small sack","large sack","tinder box","6 torches","water skin","wine skin w/wine","wolfsbane","10' pole"] 303 | 304 | # IF YOU ARE A THIEF, YOU BUY THIEVE'S TOOLS 305 | if charclass == "thief": 306 | equipment = "thieve's tools" 307 | gp = gp - 25 308 | # IF YOU ARE A CLERIC YOU BUY A HOLY SYMBOL 309 | elif charclass == "cleric": 310 | equipment = "holy symbol" 311 | gp = gp - 25 312 | # IF YOU ARE A CLERIC AND STILL HAVE > 25GP, BUY HOLY WATER 313 | if gp > 25: 314 | equipment = equipment + ", holy water" 315 | gp = gp -25 316 | # OTHER CLASSES BUY A RANDOM PIECE OF EQUIPMENT THAT EXCLUDES THOSE ABOVE 317 | else: 318 | equipment = choice(equipmentlist) 319 | # CHOOSE ANOTHER ITEM. IF THE SAME ITEM IS PICKED AS ABOVE, PICK ANOTHER 320 | equipment2 = choice(equipmentlist) 321 | while (equipment2 == equipment): 322 | equipment2 = choice(equipmentlist) 323 | # BUILD EQUIPMENT STRING 324 | equipmentlist = equipment + ", " + equipment2 325 | 326 | # PAY FOR EQUIPMENT 327 | if equipment == "backpack": 328 | gp = gp - 5 329 | elif equipment == "flask of oil": 330 | gp = gp - 2 331 | elif equipment == "hammer": 332 | gp = gp - 2 333 | elif equipment == "12 spikes": 334 | gp = gp - 1 335 | elif equipment == "lantern": 336 | gp = gp - 10 337 | elif equipment == "mirror": 338 | gp = gp - 5 339 | elif equipment == "iron rations": 340 | gp = gp - 15 341 | elif equipment == "rations": 342 | gp = gp - 5 343 | elif equipment == "50' rope": 344 | gp = gp - 1 345 | elif equipment == "small sack": 346 | gp = gp - 1 347 | elif equipment == "large sack": 348 | gp = gp - 2 349 | elif equipment == "tinder box": 350 | gp = gp - 3 351 | elif equipment == "6 torches": 352 | gp = gp - 1 353 | elif equipment == "water skin": 354 | gp = gp - 1 355 | elif equipment == "wine skin w/wine": 356 | gp = gp - 2 357 | elif equipment == "wolfsbane": 358 | gp = gp - 10 359 | elif equipment == "10' pole": 360 | gp = gp - 1 361 | else: 362 | gp = gp 363 | 364 | if equipment2 == "backpack": 365 | gp = gp - 5 366 | elif equipment2 == "flask of oil": 367 | gp = gp - 2 368 | elif equipment2 == "hammer": 369 | gp = gp - 2 370 | elif equipment2 == "12 spikes": 371 | gp = gp - 1 372 | elif equipment2 == "lantern": 373 | gp = gp - 10 374 | elif equipment2 == "mirror": 375 | gp = gp - 5 376 | elif equipment2 == "iron rations": 377 | gp = gp - 15 378 | elif equipment2 == "rations": 379 | gp = gp - 5 380 | elif equipment2 == "50' rope": 381 | gp = gp - 1 382 | elif equipment2 == "small sack": 383 | gp = gp - 1 384 | elif equipment2 == "large sack": 385 | gp = gp - 2 386 | elif equipment2 == "tinder box": 387 | gp = gp - 3 388 | elif equipment2 == "6 torches": 389 | gp = gp - 1 390 | elif equipment2 == "water skin": 391 | gp = gp - 1 392 | elif equipment2 == "wine skin w/wine": 393 | gp = gp - 2 394 | elif equipment2 == "wolfsbane": 395 | gp = gp - 10 396 | elif equipment2 == "10' pole": 397 | gp = gp - 1 398 | else: 399 | gp = gp 400 | 401 | # IF GOLD SPENT > STARTING GOLD, SET GOLD TO ZERO 402 | if gp < 1: 403 | gp = 0 404 | 405 | # BUILD TWEET 406 | tweet = charname + sex + " #" + charclass + " (" + charalign + ")\nS:" + s + " I:" + i + " W:" + w + " D:" + d + " C:" + c + " Ch:" + ch + "\n"+ achp + spellbook + weapon + weapon2 + ", " + equipmentlist + "\n" + str(gp) + "gp\n#DnD" 407 | 408 | # DETERMINE CHARACTER LENGTH OF TWEET 409 | oneforty = len(tweet) 410 | 411 | # IF TWEET IS TOO LONG, START OVER UNTIL IT IS AN APPROPRIATE LENGTH 412 | if oneforty < 141: 413 | verify = False 414 | 415 | # DISPLAY TWEET AS IT SHOULD APPEAR 416 | print(tweet) 417 | 418 | # USE Twython TO TWEET CHARACTER 419 | from twython import Twython 420 | from xxxxxxxx import ( 421 | consumer_key, 422 | consumer_secret, 423 | access_token, 424 | access_token_secret 425 | ) 426 | twitter = Twython( 427 | consumer_key, 428 | consumer_secret, 429 | access_token, 430 | access_token_secret 431 | ) 432 | twitter.update_status(status=tweet) 433 | -------------------------------------------------------------------------------- /py-generators/dungeon.py: -------------------------------------------------------------------------------- 1 | import random 2 | import networkx 3 | import matplotlib.pyplot as plt 4 | 5 | 6 | def distribution(d): 7 | s = 0 8 | lst = [] 9 | for key, val in d.items(): 10 | s += val 11 | lst.append((key, s)) 12 | r = random.uniform(0, s) 13 | for key, val in lst: 14 | if r < val: 15 | return key 16 | return key 17 | 18 | 19 | class Dungeon: 20 | 21 | def __init__(self, num_rooms=None): 22 | num_rooms = num_rooms or (7, 14) 23 | if isinstance(num_rooms, (list, tuple)): 24 | self.num_rooms = random.randint(*num_rooms) 25 | else: 26 | self.num_rooms = num_rooms 27 | self.labels = {} 28 | self.graph = None 29 | while not self.fully_connected(): 30 | self.create() 31 | 32 | def create(self): 33 | self.graph = networkx.Graph() 34 | self.labels[0] = 'E' 35 | self.graph.add_node(0) 36 | for i in range(1, self.num_rooms): 37 | self.graph.add_node(i) 38 | self.labels[i] = '{}'.format(i) 39 | self.edges = set() 40 | self.secret_edges = set() 41 | rooms = list(range(self.num_rooms)) 42 | for i in range(self.num_rooms): 43 | num = distribution({1: 85, 2: 14, 3: 1}) 44 | choices = rooms[:] 45 | choices.remove(i) 46 | for j in range(num): 47 | if not choices: 48 | break 49 | e = random.choice(choices) 50 | choices.remove(e) 51 | edge = tuple(sorted([i, e])) 52 | if edge in self.edges: 53 | continue 54 | self.edges.add(edge) 55 | if random.uniform(0, 1) < 0.05: 56 | print('SECRET {} to {}'.format(*edge)) 57 | self.secret_edges.add(edge) 58 | print('connecting {} to {}'.format(*edge)) 59 | self.graph.add_edge(*edge) 60 | 61 | def fully_connected(self): 62 | if self.graph is None: 63 | return False 64 | for i in range(1, self.num_rooms): 65 | try: 66 | path = networkx.shortest_path_length( 67 | self.graph, source=0, target=i, 68 | ) 69 | except networkx.NetworkXNoPath: 70 | print('Graph isnt fully connected! Regenerating.') 71 | return False 72 | return True 73 | 74 | def save(self, path='out.png'): 75 | import networkx 76 | import matplotlib.pyplot as plt 77 | pos = networkx.spring_layout(self.graph, iterations=500) 78 | # pos = networkx.spectral_layout(self.graph) 79 | # pos = networkx.shell_layout(self.graph) 80 | # pos = networkx.fruchterman_reingold_layout(self.graph) 81 | nodelist = list(range(self.num_rooms)) 82 | networkx.draw_networkx_nodes(self.graph, pos, nodelist=nodelist) 83 | edgelist = sorted(self.edges - self.secret_edges) 84 | secret = sorted(self.secret_edges) 85 | networkx.draw_networkx_edges(self.graph, pos, edgelist=edgelist, 86 | edge_color='k') 87 | networkx.draw_networkx_edges(self.graph, pos, edgelist=secret, 88 | edge_color='r') 89 | networkx.draw_networkx_labels(self.graph, pos, self.labels) 90 | plt.savefig(path) 91 | 92 | 93 | def main(): 94 | import argparse 95 | parser = argparse.ArgumentParser() 96 | parser.add_argument('--rooms', '-r', type=int, default=None) 97 | parser.add_argument('--output', '-o', default='out.png') 98 | args = parser.parse_args() 99 | dung = Dungeon(num_rooms=args.rooms) 100 | dung.save(path=args.output) 101 | 102 | 103 | if __name__ == '__main__': 104 | main() 105 | -------------------------------------------------------------------------------- /py-generators/monster.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import json 4 | import random 5 | import argparse 6 | from itertools import product 7 | from fuzzywuzzy import fuzz 8 | 9 | 10 | ROOT = os.path.dirname(__file__) 11 | DATADIR = os.path.join(ROOT, 'data') 12 | MONSTERS_PATH = os.path.join(DATADIR, 'monsters.json') 13 | TAGS_PATH = os.path.join(DATADIR, 'tags.json') 14 | TAG_GROUPS_PATH = os.path.join(DATADIR, 'tag_groups.json') 15 | TYPE_GROUPS_PATH = os.path.join(DATADIR, 'type_groups.json') 16 | SUBTYPE_GROUPS_PATH = os.path.join(DATADIR, 'subtype_groups.json') 17 | RELATED_PATH = os.path.join(DATADIR, 'related.json') 18 | 19 | # Easy, Medium, Hard, Deadly 20 | XP_THRESH = [ 21 | (0, 0, 0, 0), 22 | # 1st 23 | (25, 50, 75, 100), 24 | (50, 100, 150, 200), 25 | (75, 150, 225, 400), 26 | (125, 250, 375, 500), 27 | # 5th 28 | (250, 500, 750, 1100), 29 | (300, 600, 900, 1400), 30 | (350, 750, 1100, 1700), 31 | (450, 900, 1400, 2100), 32 | (550, 1100, 1600, 2400), 33 | # 10th 34 | (600, 1200, 1900, 2800), 35 | (800, 1600, 2400, 3600), 36 | (1000, 2000, 3000, 4500), 37 | (1100, 2200, 3400, 5100), 38 | (1250, 2500, 3800, 5700), 39 | # 15th 40 | (1400, 2800, 4300, 6400), 41 | (1600, 3200, 4800, 7200), 42 | (2000, 3900, 5900, 8800), 43 | (2100, 4200, 6300, 9500), 44 | (2400, 4900, 7300, 10900), 45 | # 20th! 46 | (2800, 5700, 8500, 12700), 47 | ] 48 | 49 | CRS = { 50 | 50: 'CR-1/4', 51 | 100: 'CR-1/2', 52 | 200: 'CR-1', 53 | 450: 'CR-2', 54 | 700: 'CR-3', 55 | 1100: 'CR-4', 56 | 1800: 'CR-5', 57 | 2300: 'CR-6', 58 | 2900: 'CR-7', 59 | 3900: 'CR-8', 60 | 5000: 'CR-9', 61 | 5900: 'CR-10', 62 | 7200: 'CR-11', 63 | 8400: 'CR-12', 64 | } 65 | 66 | 67 | def scale_enc(num): 68 | if num == 0: 69 | return 0.0 70 | if num == 1: 71 | return 1.0 72 | if num == 2: 73 | return 1.5 74 | if 3 <= num <= 6: 75 | return 2.0 76 | if 7 <= num <= 10: 77 | return 2.5 78 | if 11 <= num <= 14: 79 | return 3.0 80 | return 4.0 81 | 82 | 83 | def csv_set(s): 84 | if not s: 85 | return None 86 | if isinstance(s, (list, tuple, set)): 87 | return set(s) 88 | if isinstance(s, str): 89 | return {x.strip().lower() for x in s.split(',')} 90 | raise ValueError('bad type for splitting on comma: {!r}'.format(s)) 91 | 92 | 93 | class Monster: 94 | MONSTERS = [] 95 | TAG_GROUPS = {} 96 | TYPE_GROUPS = {} 97 | SUBTYPE_GROUPS = {} 98 | MONSTER_GROUPS = {} 99 | MONSTER_DATA = [] 100 | MONSTER_D = {} 101 | TAGS = set() 102 | 103 | def __repr__(self): 104 | return self.data['name'] 105 | 106 | def __init__(self, data): 107 | self.data = data 108 | 109 | def __getattr__(self, attr): 110 | if attr in self.data: 111 | val = self.data[attr] 112 | else: 113 | raise AttributeError('{!r} has no {!r}'.format(self, attr)) 114 | if isinstance(val, str): 115 | if val.isdigit(): 116 | return int(val) 117 | try: 118 | return float(val) 119 | except: 120 | return val 121 | return val 122 | 123 | def __sub__(self, mon): 124 | if not isinstance(mon, Monster): 125 | raise ValueError('cant calculate the difference between anything ' 126 | 'except other monsters') 127 | s = 0.0 128 | if self.name == mon.name: 129 | return 999999 130 | if self.type != mon.type and not (self.tags & mon.tags): 131 | return 0.0 132 | langs = {x for x in self.tags if x.endswith('_lang')} 133 | mlangs = {x for x in mon.tags if x.endswith('_lang')} 134 | tags = self.tags - langs 135 | mtags = mon.tags - mlangs 136 | s += 2 * len(langs & mlangs) 137 | areas = {'plains', 'desert', 'mountain', 'swamp', 'forest', 'jungle', 138 | 'tundra'} 139 | if tags & mtags & areas: 140 | s += 1 141 | for t in {'insect', 'arachnid', 'reptile', 'cave', 'city', 'sea', 142 | 'fresh', 'fish'}: 143 | if tags & {t} and mtags & {t}: 144 | s *= 2 145 | for t in {'underdark', 'fire', 'ice', 'lightning', 'water', 'earth', 146 | 'air', 'water', 'hell', 'fly', 'swim', 'were'}: 147 | if tags & {t} and mtags & {t}: 148 | s *= 3 149 | align = {'good', 'evil'} 150 | if len(align & tags) == 1 and len(align & mtags) == 1: 151 | if align & tags & mtags: 152 | s *= 7 153 | else: 154 | s /= 7 155 | if self.type == mon.type: 156 | if self.subtype and self.subtype == mon.subtype: 157 | s *= 8 158 | else: 159 | s *= 3 160 | for bad in {'were', 'dragon', 'reptile', 'arachnid', 'insect'}: 161 | if bad not in tags | mtags: 162 | continue 163 | if {bad} & tags & mtags != {bad}: 164 | s /= 5 165 | else: 166 | s *= 5 167 | return s 168 | 169 | @property 170 | def tags(self): 171 | if 'tags' not in self.data: 172 | return set() 173 | return set(self.data['tags']) | {self.name.strip().lower()} 174 | 175 | @classmethod 176 | def load(cls): 177 | with open(MONSTERS_PATH) as f: 178 | cls.MONSTER_DATA = json.load(f) 179 | cls.MONSTERS = [cls(x) for x in cls.MONSTER_DATA] 180 | cls.MONSTER_D = {m.name.lower().strip(): m for m in cls.MONSTERS} 181 | 182 | with open(TAGS_PATH) as f: 183 | cls.TAGS = set(json.load(f)) 184 | 185 | with open(TAG_GROUPS_PATH) as f: 186 | tag_groups = json.load(f) 187 | cls.TAG_GROUPS = {} 188 | for tag, mon_names in tag_groups.items(): 189 | mons = [cls.get(name) for name in mon_names] 190 | cls.TAG_GROUPS[tag] = mons 191 | 192 | with open(TYPE_GROUPS_PATH) as f: 193 | type_groups = json.load(f) 194 | cls.TYPE_GROUPS = {} 195 | for typ, mon_names in type_groups.items(): 196 | mons = [cls.get(name) for name in mon_names] 197 | cls.TYPE_GROUPS[typ] = mons 198 | 199 | with open(SUBTYPE_GROUPS_PATH) as f: 200 | subtype_groups = json.load(f) 201 | cls.SUBTYPE_GROUPS = {} 202 | for typ, d in subtype_groups.items(): 203 | for subtyp, mon_names in d.items(): 204 | if subtyp == 'null': 205 | key = (typ, None) 206 | else: 207 | key = (typ, subtyp) 208 | mons = [cls.get(name) for name in mon_names] 209 | cls.SUBTYPE_GROUPS[key] = mons 210 | 211 | with open(RELATED_PATH) as f: 212 | related = json.load(f) 213 | cls.MONSTER_GROUPS = {} 214 | for name, mon_names in related.items(): 215 | mons = [cls.get(mname) for mname in mon_names] 216 | cls.MONSTER_GROUPS[cls.get(name)] = mons 217 | 218 | @classmethod 219 | def get(cls, name): 220 | mon = cls.MONSTER_D.get(name.strip().lower()) 221 | if mon: 222 | return mon 223 | mons = [] 224 | for mon in cls.MONSTERS: 225 | ratio = fuzz.ratio(mon.name.lower().strip(), name) 226 | mons.append((ratio, mon)) 227 | mons = [b for a, b in sorted(mons, key=lambda x: x[0], reverse=True)] 228 | return mons[0] 229 | 230 | def related(self): 231 | return Monster.MONSTER_GROUPS[self] 232 | 233 | @staticmethod 234 | def select_and(grp1, grp2): 235 | return [x for x in grp1 if x in grp2] 236 | 237 | @staticmethod 238 | def select_or(grp1, grp2): 239 | g1 = [x for x in grp1 if x not in grp2] 240 | g2 = [x for x in grp2 if x not in grp1] 241 | return g1 + g2 242 | 243 | @classmethod 244 | def random_encounter(cls, min_xp, max_xp, or_tags=None, and_tags=None, 245 | not_tags=None, max_num=10): 246 | mons = cls.find(or_tags=or_tags, and_tags=and_tags, not_tags=not_tags) 247 | if not mons: 248 | raise ValueError('filters too restrictive! no monsters found') 249 | mons = [x for x in mons if x.xp <= max_xp] 250 | if not mons: 251 | raise ValueError('none of these monsters are <= max xp threshold') 252 | print('found {} possible monsters'.format(len(mons))) 253 | 254 | mon = random.choice(mons) 255 | mons = cls.select_and(mon.related(), mons)[:6] 256 | 257 | def total_xp(enc): 258 | xp = 0 259 | c = 0 260 | for ct, mon in enc: 261 | xp += ct * mon.xp 262 | c += ct 263 | xp *= scale_enc(c) 264 | return xp 265 | 266 | print('trying to build with types: {}' 267 | .format(', '.join([x.name for x in mons]))) 268 | 269 | poss = [] 270 | amounts = [] 271 | for _ in mons: 272 | # maximum of 10 of each monster 273 | amounts.append(range(max_num)) 274 | print('iterating through {} possible encounter permutations...'.format( 275 | max_num**len(mons))) 276 | 277 | for cts in product(*amounts): 278 | if len([x for x in cts if x > 0]) > 4: 279 | continue 280 | enc = [] 281 | for i, ct in enumerate(cts): 282 | if ct == 0: 283 | continue 284 | mon = mons[i] 285 | enc.append((ct, mon)) 286 | if min_xp <= total_xp(enc) <= max_xp: 287 | poss.append(enc) 288 | 289 | print('{} of those match allowed XP values'.format(len(poss))) 290 | if not poss: 291 | raise ValueError('no possible permutations amount to allowed XP!') 292 | enc = random.choice(poss) 293 | return enc, total_xp(enc) 294 | 295 | @classmethod 296 | def cr_encounters(cls, min_xp, max_xp, max_num=10): 297 | def total_xp(enc): 298 | xp = 0 299 | c = 0 300 | for ct, mon in enc: 301 | xp += ct * mon[1] 302 | c += ct 303 | xp *= scale_enc(c) 304 | return xp 305 | mons = [] 306 | for k, v in CRS.items(): 307 | if k > max_xp: 308 | continue 309 | if k < min_xp / 20: 310 | continue 311 | mon = (v, k) 312 | mons.append(mon) 313 | amounts = [] 314 | for _ in mons: 315 | amounts.append(range(max_num)) 316 | poss = [] 317 | for cts in product(*amounts): 318 | if len([x for x in cts if x > 0]) > 3: 319 | continue 320 | enc = [] 321 | for i, ct in enumerate(cts): 322 | if ct == 0: 323 | continue 324 | mon = mons[i] 325 | enc.append((ct, mon)) 326 | if min_xp <= total_xp(enc) <= max_xp: 327 | poss.append((enc, total_xp(enc))) 328 | def summ(enc): 329 | return sum([e[0] for e in enc]) 330 | return sorted(poss, key=lambda x: (x[1], summ(x[0]))) 331 | 332 | @classmethod 333 | def custom_random_encounter(cls, monsters, min_xp, max_xp, max_num=10): 334 | mons = [] 335 | for mon in monsters: 336 | if isinstance(mon, str): 337 | if '=' in mon: 338 | name, xp = mon.split('=') 339 | xp = int(xp) 340 | mons.append((name, xp)) 341 | else: 342 | mon1 = Monster.get(mon) 343 | if mon1 is None: 344 | raise ValueError( 345 | 'couldnt find monster from {!r}'.format(mon) 346 | ) 347 | mons.append((mon1.name, mon1.xp)) 348 | elif isinstance(mon, tuple): 349 | if len(mon) != 2: 350 | raise ValueError('got tuple {!r}, shouldve been (name, xp)' 351 | .format(mon)) 352 | if isinstance(mon[1], int): 353 | mons.append(mon) 354 | else: 355 | mons.append((mon[0], int(mon[1]))) 356 | 357 | def total_xp(enc): 358 | xp = 0 359 | c = 0 360 | for ct, name, _xp in enc: 361 | xp += ct * _xp 362 | c += ct 363 | xp *= scale_enc(c) 364 | return xp 365 | 366 | poss = [] 367 | amounts = [] 368 | for _ in mons: 369 | # maximum of 10 of each monster 370 | amounts.append(range(max_num)) 371 | print('iterating through {} possible encounter permutations...'.format( 372 | max_num**len(mons))) 373 | 374 | for cts in product(*amounts): 375 | enc = [] 376 | for i, ct in enumerate(cts): 377 | if ct == 0: 378 | continue 379 | mon = mons[i] 380 | enc.append((ct, mon[0], mon[1])) 381 | if min_xp <= total_xp(enc) <= max_xp: 382 | poss.append(enc) 383 | 384 | print('{} of those match allowed XP values'.format(len(poss))) 385 | if not poss: 386 | raise ValueError('no possible permutations amount to allowed XP!') 387 | enc = random.choice(poss) 388 | return enc, total_xp(enc) 389 | 390 | @classmethod 391 | def find(cls, or_tags=None, and_tags=None, not_tags=None): 392 | or_tags = csv_set(or_tags) 393 | and_tags = csv_set(and_tags) 394 | not_tags = csv_set(not_tags) 395 | mons = cls.MONSTERS[:] 396 | if or_tags is not None: 397 | mons = [x for x in mons if x.tags & or_tags] 398 | if and_tags is not None: 399 | mons = [x for x in mons if x.tags & and_tags == and_tags] 400 | if not_tags is not None: 401 | mons = [x for x in mons if not bool(x.tags & not_tags)] 402 | return mons 403 | 404 | def short_output(self): 405 | print('{} ({}{}) CR:{} XP:{}'.format( 406 | self.name, self.type, ' ' + self.subtype if self.subtype else '', 407 | self.challenge_rating, self.xp)) 408 | print('AC:{} HP:{} ({})'.format(self.armor_class, self.hit_points, 409 | self.hit_dice)) 410 | print('S:{} D:{} C:{} I:{} W:{} CH:{}'.format( 411 | self.strength, self.dexterity, self.constitution, self.intelligence, 412 | self.wisdom, self.charisma)) 413 | print('Size: {}'.format(self.size)) 414 | print('Speed: {}'.format(self.speed)) 415 | print('Senses: {}'.format(self.senses)) 416 | if self.damage_immunities: 417 | print('Immune: {}'.format(self.damage_immunities)) 418 | if self.condition_immunities: 419 | print('Cond.Immune: {}'.format(self.condition_immunities)) 420 | if self.damage_resistances: 421 | print('Resist: {}'.format(self.damage_resistances)) 422 | if self.damage_vulnerabilities: 423 | print('Vulnerable: {}'.format(self.damage_vulnerabilities)) 424 | if self.languages: 425 | print('Langs: {}'.format(self.languages)) 426 | if 'actions' in self.data: 427 | for act in self.actions: 428 | print('Action "{act[name]}": {act[desc]}'.format(act=act)) 429 | if 'special_abilities' in self.data: 430 | for abi in self.special_abilities: 431 | print('Ability "{abi[name]}": {abi[desc]}'.format(abi=abi)) 432 | 433 | def output(self): 434 | print(self.name) 435 | for x in ( 436 | 'name', 437 | 'challenge rating', 438 | 'xp', 439 | 'type', 440 | 'subtype', 441 | 'alignment', 442 | 'size', 443 | 'hit points', 444 | 'hit dice', 445 | 'armor class', 446 | 'speed', 447 | 'senses', 448 | 'languages' 449 | 'damage immunities', 450 | 'damage resistances', 451 | 'damage vulnerabilities', 452 | 'condition immunities', 453 | 'strength', 454 | 'dexterity', 455 | 'constitution', 456 | 'intelligence', 457 | 'wisdom', 458 | 'charisma', 459 | ): 460 | if x not in self.data: 461 | continue 462 | print('{}: {}'.format(x.title(), self.data[x])) 463 | print('Tags: {}'.format(', '.join(self.tags))) 464 | if self.subtype: 465 | print('Same Subtype: {}'.format(', '.join([ 466 | x.name for x in self.same_subtype() 467 | ]))) 468 | print('Same Type: {}'.format(', '.join([ 469 | x.name for x in self.same_type() 470 | ]))) 471 | print('Related: {}'.format(', '.join([ 472 | x.name for x in self.related()[:10] 473 | ]))) 474 | if 'actions' in self.data: 475 | print('\nActions:') 476 | for act in self.actions: 477 | print(' ' + act['name']) 478 | print(' ' + act['desc']) 479 | if 'special_abilities' in self.data: 480 | print('\nSpecial Abilities:') 481 | for act in self.special_abilities: 482 | print(' ' + act['name']) 483 | print(' ' + act['desc']) 484 | 485 | def same_type(self): 486 | return self.TYPE_GROUPS[self.type] 487 | 488 | def same_subtype(self): 489 | return self.SUBTYPE_GROUPS[(self.type, self.subtype or None)] 490 | 491 | 492 | def calc_threshold(player_levels): 493 | thresh = [0, 0, 0, 0, 0] 494 | for lvl in player_levels: 495 | for i in range(4): 496 | thresh[i] += XP_THRESH[lvl][i] 497 | # make deadly span between itself and a new number, 1.5 times diff between 498 | # the hard and deadly difficulty difference 499 | d = thresh[3] - thresh[2] 500 | thresh[4] = int(thresh[3] + (1.5 * d)) 501 | return thresh 502 | 503 | 504 | def main_monster(): 505 | parser = argparse.ArgumentParser() 506 | parser.add_argument('name', help='select a monster by name') 507 | parser.add_argument('--short', '-s', action='store_true', 508 | help='print short stats') 509 | args = parser.parse_args() 510 | Monster.load() 511 | mon = Monster.get(args.name) 512 | if not mon: 513 | sys.exit('cant find this monster') 514 | if args.short: 515 | mon.short_output() 516 | else: 517 | mon.output() 518 | 519 | 520 | def main_encounter(): 521 | p = argparse.ArgumentParser() 522 | p.add_argument('--players', '-p', help='the player levels, default 1,1,1,1') 523 | p.add_argument('--difficulty', '-d', default='medium', 524 | choices=('easy', 'medium', 'hard', 'deadly')) 525 | p.add_argument('--and', '-A', dest='and_tags', 526 | help='require monsters have all of these, ' 527 | 'eg: underdark,undercommon_lang') 528 | p.add_argument('--or', '-O', dest='or_tags', 529 | help='only include monsters with one or more, eg: ' 530 | 'dragon,reptile') 531 | p.add_argument('--not', '-N', dest='not_tags', 532 | help='exclude monsters with one of these, eg: undead,fire') 533 | p.add_argument('--custom', '-c', 534 | help='specify custom set of monsters with name=xp notation,' 535 | ' eg. elfmage=500,treeperson=1500,goblin,goblinmage=200') 536 | p.add_argument('--max-num', '-m', type=int, default=10, 537 | help='for custom encounters, the maximum number of one type,' 538 | 'eg. "--max-num 5" if you only want up to 5 of each type, ' 539 | 'default: %(default)s') 540 | args = p.parse_args() 541 | Monster.load() 542 | if args.players: 543 | players = [int(x.strip()) for x in args.players.split(',')] 544 | else: 545 | players = [1, 1, 1, 1] 546 | thresh = calc_threshold(players) 547 | diff = {'easy': 0, 'medium': 1, 'hard': 2, 'deadly': 3}[args.difficulty] 548 | thresh = (thresh[diff], thresh[diff + 1]) 549 | if not args.custom: 550 | try: 551 | enc, xp = Monster.random_encounter( 552 | thresh[0], 553 | thresh[1], 554 | or_tags=args.or_tags, 555 | and_tags=args.and_tags, 556 | not_tags=args.not_tags, 557 | ) 558 | except ValueError as e: 559 | sys.exit(str(e)) 560 | print('XP={} ({} <= xp <= {}):'.format(xp, *thresh)) 561 | for ct, mon in enc: 562 | print(' - {} {!r}'.format(ct, mon)) 563 | print('') 564 | for ct, mon in enc: 565 | mon.short_output() 566 | print('') 567 | else: 568 | mons = [x.strip() for x in args.custom.split(',') if x.strip()] 569 | try: 570 | enc, xp = Monster.custom_random_encounter( 571 | mons, thresh[0], thresh[1], max_num=args.max_num, 572 | ) 573 | except ValueError as e: 574 | sys.exit(str(e)) 575 | print('XP={} ({} <= xp <= {}):'.format(xp, *thresh)) 576 | for ct, name, mon_xp in enc: 577 | print(' - {} {} (xp={})'.format(ct, name, mon_xp)) 578 | 579 | 580 | def main_threshold(): 581 | p = argparse.ArgumentParser() 582 | p.add_argument('--players', '-p', help='the player levels, default 1,1,1,1') 583 | args = p.parse_args() 584 | if args.players: 585 | players = [int(x.strip()) for x in args.players.split(',')] 586 | else: 587 | players = [1, 1, 1, 1] 588 | thresh = calc_threshold(players) 589 | print('Easy: {} to {}'.format(thresh[0], thresh[1] - 1)) 590 | print('Medium: {} to {}'.format(thresh[1], thresh[2] - 1)) 591 | print('Hard: {} to {}'.format(thresh[2], thresh[3] - 1)) 592 | print('Deadly: {}+'.format(thresh[3])) 593 | 594 | 595 | def main_summary(): 596 | p = argparse.ArgumentParser() 597 | p.add_argument('--players', '-p', help='the player levels, default 1,1,1,1') 598 | p.add_argument('--difficulty', '-d', default='medium', 599 | choices=('easy', 'medium', 'hard', 'deadly')) 600 | p.add_argument('--max-num', '-m', type=int, default=10, 601 | help='for custom encounters, the maximum number of one type,' 602 | 'eg. "--max-num 5" if you only want up to 5 of each type, ' 603 | 'default: %(default)s') 604 | args = p.parse_args() 605 | if args.players: 606 | players = [int(x.strip()) for x in args.players.split(',')] 607 | else: 608 | players = [1, 1, 1, 1] 609 | diff = {'easy': 0, 'medium': 1, 'hard': 2, 'deadly': 3}[args.difficulty] 610 | thresh = calc_threshold(players) 611 | encs = Monster.cr_encounters(thresh[diff], thresh[diff + 1] - 1, 612 | max_num=args.max_num) 613 | print('XP {} to {}'.format(thresh[diff], thresh[diff + 1] - 1)) 614 | for enc, xp in encs: 615 | print('XP: {}'.format(xp)) 616 | for ct, mon in enc: 617 | print(' - {} {} ({})'.format(ct, mon[0], mon[1])) 618 | print('') 619 | -------------------------------------------------------------------------------- /randomUtil.js: -------------------------------------------------------------------------------- 1 | // Note: Non of these methods are up to crypto-safe random standards. Don't use them for crypto ever. 2 | 3 | // Ref: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random 4 | // Random number inclusive of both values, defaults to 1 -> inclusiveMax 5 | randPosInt = function(inclusiveMax, min) { 6 | return Math.floor(Math.random() * (inclusiveMax - min + 1)) + min; 7 | } 8 | exports.randPosInt = randPosInt 9 | 10 | randIntMinOne = function(inclusiveMax) { 11 | return randPosInt(inclusiveMax, 1); 12 | } 13 | exports.randIntMinOne = randIntMinOne 14 | 15 | randIntMinZero = function(inclusiveMax) { 16 | return randPosInt(inclusiveMax, 0); 17 | } 18 | exports.randIntMinZero = randIntMinZero 19 | 20 | randArrIdx = function(array) { 21 | if (array) { 22 | return randPosInt(array.length - 1, 0) 23 | } else { 24 | console.log('Requested array index from nonarray: ' + array + ' -- returning NaN') 25 | return NaN 26 | } 27 | } 28 | exports.randArrIdx = randArrIdx 29 | 30 | // Give back a random item (or set of items) from a list 31 | exports.choice = function(array = [], numChoices = 1) { 32 | if (numChoices == 1) { 33 | let idx = randArrIdx(array) 34 | return array[idx] 35 | } else if (numChoices > 1) { 36 | if (numChoices >= array.length - 1) return array; 37 | let result = [] 38 | while (numChoices-- > 0) { 39 | let idx = randArrIdx(array) 40 | result.push(array.splice(idx, 1)[0]) //Splice returns array 41 | } 42 | return result 43 | } 44 | } 45 | 46 | //https://stackoverflow.com/a/12646864/6794180 - No native shuffle functions. Bummer. 47 | //Needed to smash up our data arrays for randomness 48 | exports.shuffleArray = function(inputArray) { 49 | let array = inputArray.slice(0) //Quick clone 50 | for (var i = array.length - 1; i > 0; i--) { 51 | var j = Math.floor(Math.random() * (i + 1)); 52 | var temp = array[i]; 53 | array[i] = array[j]; 54 | array[j] = temp; 55 | } 56 | return array; 57 | } -------------------------------------------------------------------------------- /service.sh: -------------------------------------------------------------------------------- 1 | ps auxww | grep node | grep -v grep || echo its not there 2 | ^ Way to start it 3 | 4 | -------------------------------------------------------------------------------- /service/autoupdate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | ################################### 5 | # Set as a root cron job, with the 6 | # correct path, and your bot will 7 | # auto update if there's a version 8 | # change (or any package change) 9 | # 10 | # I set mine for every 2 minutes with a daily log cleanup. Ex: 11 | # 12 | # */2 * * * * /path/to/this/autoupdate.sh >> /var/log/botupdate.log 2>> /var/log/botupdate.log 13 | # 0 2 * * * rm /var/log/botupdate.log 14 | # 15 | # - jakethedev 16 | ################################### 17 | 18 | cd /opt/tavernbot 19 | cp package.json package.old 20 | git pull > gitpull.log 21 | diff -w package.old package.json || systemctl restart tavernbot 22 | -------------------------------------------------------------------------------- /service/cron_templates: -------------------------------------------------------------------------------- 1 | # Cron to keep the bot updated, only relevant if you like the idea of git-push deploying your code 2 | 3 | */2 * * * * /opt/tavernbot/service/autoupdate.sh >> /var/log/botupdate.log 2>> /var/log/botupdate.log 4 | -------------------------------------------------------------------------------- /service/install-service.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # SYSTEMD SETUP 3 | 4 | echo "Copying service file to /etc/systemd/system..." 5 | systemd --version 6 | # If you have 236+, uncomment the StandardOutput/Error lines in the service if you'd like to output 7 | # logs and errors to custom locations. Else it's journalctl for you 8 | sudo cp tavernbot.service /etc/systemd/system/ 9 | 10 | echo "Reloading daemon, enabling and starting tavernbot service" 11 | sudo systemctl daemon-reload 12 | systemctl status tavernbot # It should say 'not loaded' 13 | sudo systemctl enable tavernbot 14 | sudo systemctl start tavernbot 15 | sleep 1 16 | sudo journalctl -xe 17 | # You should see healthy output and a logged-in message 18 | 19 | echo "Setting up logrotate" 20 | sudo ln -s $(pwd)/logrotate.tavern /etc/logrotate.d/ 21 | -------------------------------------------------------------------------------- /service/install-tavernbot.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # SYSTEMD SETUP 3 | 4 | echo "Copying service file to /etc/systemd/system..." 5 | systemd --version 6 | # If you have 236+, uncomment the StandardOutput/Error lines in the service if you'd like to output 7 | # logs and errors to custom locations. Else it's journalctl for you 8 | sudo cp tavernbot.service /etc/systemd/system/ 9 | 10 | echo "Reloading daemon, enabling and starting tavernbot service" 11 | sudo systemctl daemon-reload 12 | systemctl status tavernbot # It should say 'not loaded' 13 | sudo systemctl enable tavernbot 14 | sudo systemctl start tavernbot 15 | sudo journalctl -f 16 | # You should see healthy output and a logged-in message 17 | 18 | -------------------------------------------------------------------------------- /service/logrotate.tavern: -------------------------------------------------------------------------------- 1 | /var/log/tavernbot.log { 2 | rotate 2 3 | daily 4 | missingok 5 | notifempty 6 | compress 7 | postrotate 8 | systemctl restart tavernbot 9 | endscript 10 | } 11 | 12 | -------------------------------------------------------------------------------- /service/tavernbot.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Tavernbot: The best RPG bot for Discord 3 | Documentation=https://github.com/jakerunsdnd/tavernbot 4 | After=network.target 5 | 6 | [Service] 7 | Type=simple 8 | Environment=NODE_ENV=production 9 | WorkingDirectory=/opt/tavernbot 10 | ExecStart=/usr/bin/node bot.js >> /opt/tavernbot/tavernbot.log 11 | Restart=on-failure 12 | RestartSec=10s 13 | #Only available in systemd 236+ 14 | #StandardOutput=file:/var/log/bot.log 15 | #StandardError=file:/var/log/bot.err 16 | 17 | [Install] 18 | WantedBy=multi-user.target 19 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | /* 2 | Note to any readers: 3 | 4 | I don't, for one second, think this is a substitute for unit testing. 5 | I just wanted a way to continue bolting down code and ensuring it all 6 | works smoothly during intense construction and refactoring, and this 7 | is surprisingly maintainable for the moment. 8 | 9 | When it gets hairy, I'll bring on chai + mocha. Which will probably be soon. 10 | */ 11 | 12 | 13 | const dungeonary = require('./dungeonary') 14 | const discordlib = require('./discordlib') 15 | require('./randomUtil') 16 | const { 17 | token, 18 | botkey, 19 | botRole, 20 | activeChannels, 21 | gameStatus 22 | } = require("./config.json") 23 | 24 | let msgs = [ 25 | { "content": "!coin", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 26 | { "content": "!coin 20", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 27 | { "content": "!hook", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 28 | { "content": "!hook low", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 29 | { "content": "!hook hi", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 30 | { "content": "!hook cy", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 31 | { "content": "!hook st", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 32 | { "content": "!hook spa", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 33 | { "content": "!hook mod", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 34 | { "content": "!spell", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 35 | { "content": "!monster", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 36 | { "content": "!rollstats", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 37 | { "content": "!rollstats 4d6k3", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 38 | { "content": "!rollstats 2d6+6", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 39 | { "content": "!rollstats colville", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 40 | { "content": "!rollstats funnel", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 41 | { "content": "!rollstats 3d6", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 42 | { "content": "!roll", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 43 | { "content": "!roll 1d20 + 5", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 44 | { "content": "!roll 2d20 + 5d4", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 45 | { "content": "!roll 50d20 + d6 + 9", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 46 | { "content": "!roll d6+d6+d6", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 47 | { "content": "!roll -7+1d6", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 48 | { "content": "!roll 1d6 for schwiiing", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 49 | { "content": "!roll 1d20+5 to hit, 2d6+3 dmg, 1d6 poison, 1d20-1 con save", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 50 | { "content": "!roll 49d32 + 3d6 + 9 + 7 - 2 for meteor shower", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 51 | { "content": "!roll FIREBALL", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } }, 52 | { "content": "!roll 1d20 + 5 + 2d6, 9d8 + 1, d6 + d6 + d6 + d6", "author": { 'username': "testuser" }, "reply": console.log, "channel": { "name": "golemworks" } } 53 | ] 54 | 55 | for (msg of msgs) { 56 | // Let's hook it up for a default channel and DMs 57 | if (activeChannels.includes(msg.channel.name.toLowerCase()) || msg.channel.recipient) { 58 | //Make sure we care, and that we're not making ourselves care 59 | if (!msg.content.trim().startsWith(botkey) || msg.author.bot) return 60 | //Remove botkey and break it up into clean not-mixed-cased parts. 61 | let parts = msg.content.trim().toLowerCase().substring(1).split(/\s+/) 62 | let cmd = parts[0] 63 | let input = parts[1] ? parts.slice(1).join(' ') : '' //Some cmds have no input, this lets us use if(input) 64 | let execTime = new Date(Date.now()).toLocaleString() + ': '; 65 | //From here, we check each lib until we find a match for execution, or we let the user know it's a no-go 66 | if (cmd in dungeonary) { 67 | console.log(execTime + 'running dungeonary.' + cmd + '(' + input + ') for ' + msg.author.username) 68 | msg.reply(dungeonary[cmd](input)) 69 | } else if (cmd in discordlib) { 70 | console.log(execTime + 'running discordlib.' + cmd + '(' + input + ') for ' + msg.author.username) 71 | msg.reply(discordlib[cmd](input, msg, client)) 72 | } else { 73 | msg.reply("I'm sorry " + msg.author.username + ", I'm afraid I can't do that") 74 | } 75 | } 76 | } 77 | --------------------------------------------------------------------------------