Part 4: Parseltongue
This series of posts is dedicated to building tools that support a simulation of simplified D&D gameplay. To implement the simulation I’ll be using Python, PostgreSQL, Elastic, and Docker. The idea behind these blog posts is to document my progress through this project and interesting bits I find along the way. If you’d like to start from the beginning, have a look at part 1, where I talked about where this idea came from, and what I’d like to accomplish. In this part, I walk through the pieces of Python that will be used for the project, how to get the example code, and show an example of how to test that code.
For this project I will be using Python version 3.6 or better. If you’d like to follow along, realpython.com has a great set of doc to help get it installed.
Once Python is installed, this should work without error:
~$ python3.6 --version Python 3.6.5 OR ~$ python --version Python 3.6.5
The next thing to do is create a spot to set up specific Python modules for just this project. Python calls this a virtual environment. There are several ways to do this, and up until version 3.3 the setup was a bit kludgy. With the 3.3 release setting up a virtual environment became a lot easier.
~$ mkdir python_envs ~$ cd python_envs ~/python_envs$ python3.6 -m venv rpg ~/python_envs$ ls rpg ~/python_envs$ . rpg/bin/activate (rpg) ~/python_envs$
Notice that the prompt changed with no errors. The virtual environment has been activated. Next use the Python package manager, pip, to make sure that nothing has been added to the virtual environment yet. The “freeze” command outputs any installed python packages. No output means that there are none installed.
(rpg) ~/python_envs$ pip freeze (rpg) ~/python_envs$
Good. Time to get the project code from github and set up this virtual environment for our project.
(rpg) ~/python_envs$ cd ~ (rpg) ~$ mkdir Git (rpg) ~$ cd Git (rpg) ~/Git$ git clone https://github.com/mdbdba/python_rpg_sim.git
OR if you don’t have git installed, browse to https://github.com/mdbdba/python_rpg_sim , click on the green “clone or download” button on the right side of the page, and choose “download zip”. Unzip the downloaded file under the Git directory you just made and things should work out.
(rpg) ~/Git$ cd python_rpg_sim/Python (rpg) ~/Git/python_rpg_sim/Python$ pip install -r requirements.txt Collecting atomicwrites==1.2.1 (from -r requirements.txt (line 1)) ... Installing collected packages: atomicwrites, attrs, six, more-itertools, pluggy, psycopg2, py, pytest Successfully installed atomicwrites-1.2.1 attrs-18.2.0 more-itertools-4.3.0 pluggy-0.7.1 psycopg2-2.7.5 py-1.6.0 pytest-3.8.2 six-1.11.0 (rpg) ~/Git/python_rpg_sim/Python$ pip freeze atomicwrites==1.2.1 attrs==18.2.0 more-itertools==4.3.0 pluggy==0.7.1 psycopg2==2.7.5 py==1.6.0 pytest==3.8.2 six==1.11.0
The contents of the requirements.txt file told pip which packages and versions to install. Once the install completed successfully, I issued the “freeze” command again to make sure that the installed packages were actually reported.
In the last post I talked about a Die class that will be used to add randomization to our project. The python_rpg_sim/Python/Die.py file is a revised version of that. Two things to notice about the new class:
1) When an instance of the class is created, it now can take an argument called debugInd that defaults to False. If True is passed for that argument, then an array of lists, called classEval, is populated. classEval will contain a header (classEval[0]) with the arguments passed on creation and a list documenting each time one of the methods is called. That allows us to build tests around that data.
2) There is a test file, python_rpg_sim/Python/test/test_die.py, that can be used with the pytest package, to automate our testing. To do that, I can call the test file directly:
(rpg) ~/Git/python_rpg_sim/Python$ pytest test/test_die.py ============================= test session starts ============================== platform linux -- Python 3.6.5, pytest-3.8.2, py-1.6.0, pluggy-0.7.1 rootdir: ~/Git/python_rpg_sim/Python, inifile: collected 23 items test/test_die.py ....................... [100%] ========================== 23 passed in 0.03 seconds ===========================
The usual Test Driven Development process would be that I’d write the case first, prove that the case fails, write the code to satisfy the test, and then see the test pass. I’ve short circuited that process a bit here, just to see it all work. I’ll get to all the failing soon enough.
Looking at the test_die.py file. It’s apparent that I went overboard making tests, but the pattern for each is that if there’s not just a straight sum that we’re testing for, we can get back the classEval array and look at the last list in it to see if we handled things right.
If we were supposed to drop a value, did the sorted array have one more value than what became the array that was summed up? By getting the current classEval array, the ‘array_sorted’ value array will hold the rolled values before any dropping occurs and the ‘array’ value array will hold the rolled values after the drop. So, comparing the count of contents in those two arrays should have a difference of one. Below, a d6 is being rolled four times and the lowest value should be dropped. Checking to make sure that the count of the ‘array_sorted’ value array should then equal 4 and the count of the ‘array’ value array should then equal 3. The way this is done In a pytest file is with the assert statement.
def test_3d6_droplowest(): d = Die(6, True) r = d.roll(4, True) assert(len(d.getClassEval()[-1]['array_sorted']) == 4) assert(len(d.getClassEval()[-1]['array']) == 3) assert(18 >= r >= 3)
For an advantage roll, was the highest value actually the one that was returned? For this example, the ‘array’ and ‘array_sorted’ value array counts still need to have a difference of one, and adding another assert that looks through the ‘array_sorted” value array for it’s max value and makes sure it matches the value returned from the rollWithAdvantage method.
def test_d20_withadvantage(): d = Die(20, True) r = d.rollWithAdvantage() assert(len(d.getClassEval()[-1]['array_sorted']) == 2) assert(len(d.getClassEval()[-1]['array']) == 1) assert(max(d.getClassEval()[-1]['array_sorted']) == r) assert(20 >= r >= 1)
On a disadvantage roll, was the lowest value returned? By looking at the min value from ‘array_sorted’ the rollWithDisadvantage could be tested.
def test_d20_withdisadvantage(): d = Die(20, True) r = d.rollWithDisadvantage() assert(len(d.getClassEval()[-1]['array_sorted']) == 2) assert(len(d.getClassEval()[-1]['array']) == 1) assert(min(d.getClassEval()[-1]['array_sorted']) == r) assert(20 >= r >= 1)
And, for a resistance roll was the total actually half the total?
def test_4d6_withresistance(): d = Die(6, True) r = d.rollWithResistance(4) assert(d.getClassEval()[-1]['total_halved'] == r) assert(12 >= r >= 2)
We also want to make sure that when we don’t pass a value for the debugInd it isn’t doing all that work populating the array. In case the instance of the class is created without debugging, the call to getClassEval should return something to give something using the output a nice indicator of what’s going on.
def test_d6(): d = Die(6) r = d.roll(1) assert(6 >= r >= 1) assert(d.getClassEval()[0]['debugInd'] is False)
That’s a lot of ground to cover in one post. Python is all set up, the code is available, the packages for the project are now set up in a virtual environment, and there’s a process for testing future changes. In the next post, it’ll be time to talk about Abilities in D&D, and rolling for them. Until then, go play!