Dungeon 116
Thoughts on ‘Tiny Habits’, maybe a change to Monster behavior, maybe poison.
I’ve started reading Tiny Habits, by BJ Fogg. It’s an interesting scientific take on how to form habits we want and how to break ones that we don’t want. It won’t spoil the book to mention this much,
According to Fogg, Behavior occurs when there is Motivation, Ability, and a Prompt. Motivation means that we do want to do the thing, Ability means that we can in fact do the thing, and Prompt is an event that triggers the behavior.
I was thinking about TDD, Test-Driven Development, in that light. I certainly want to do it: I’ve found that, for me, TDD makes development go faster and feel better. And I’m certain that I have the general ability to do it, since I’ve been doing it most of the time for over two decades. So I have the Motivation and the Ability. And yet, sometimes I don’t do it. I want to think about that, and to share those thoughts with you.
Now, sometimes I don’t see how to write a test, which suggests a reduction in Ability in this case. And sometimes tests are difficult and tedious to write, which reduces my Motivation, and perhaps my Ability.
But where’s the Prompt? What is the event, the discernible moment, that should trigger me to write the test?
Fogg gives the example of flossing. He suggests putting your favorite floss near the toothbrush and setting it on the sink before you start brushing. Brushing, a thing you probably already do at least every few days, then serves as the Prompt for flossing. (He also suggests, in the spirit of tiny habits, that you only commit to flossing one tooth. More is better, but the habit starts out as just one.)
Kent Beck used to tell the tale of when his young daughter wanted to help him program, and he asked Bethany to ask, from time to time, “do you have a test for that?” That tells me two things. First, it tells me that Kent probably wasn’t doing TDD perfectly either, and second, it probably helps to explain why Beth is now an expert and thoughtful developer.
I tried getting Kitty to ask me about tests, but her interest waned quickly. She’s more about tuna and napping and watching the wildlife out back. So I still need a prompt.
It’s clear, of course, that I need help on all three. When tests are hard to write, I am less Motivated. I could work to make them easier to write in those cases. Some of our recent work with character sheets and attributes has helped with that, but there are some situations where the setup seems too much. I could work on that and it might be interesting, and would be helpful.
A Prompt for that might be the feeling that oh hell this is too much of a pain to test. I could use that to try a tiny improvement to testability.
As for Ability, there are some things that I just can’t figure out how to test them, or at least it’s harder to figure that out than it is to write them. Random behavior is a big part of the game, and testing random behavior is difficult. I’ve improved that situation a bit using fake random number sequences, or making sure that there are methods that the random decisions select among, and testing those methods underneath. I could work on improving my Ability.
A Prompt for that might be the feeling that I have no idea how to test this thing. I could use that to do a little design improvement or to write some kind of fake object, or to do something to make the thing testable.
But day in and day out, even with the Motivation and Ability in place, what is the Prompt for “Let’s begin with a test”?
Hm. Writing that reminds me. I used to start each bit of development by saying, and writing in the article or chapter I was working on: Begin with a test. Saying that quickly became a habit and it tended to trigger me into the testing behavior. So maybe I would do well to start saying that again. I’ll try it.
What else might prompt me? What might prompt me better than that? If you have ideas, tweet me up. But I’ve just had another one:
I could change the program to ask me if I have a test for that. And I could make it only ask if CodeaUnit is bound in.
Let’s do that, for fun.
Do You Have a Test for That?
The story is:
When the game starts, if CodeaUnit is available, begin and end the opening crawl with the line: DO YOU HAVE A TEST FOR THAT?
Here’s where that happens:
function GameRunner:createLevel(count)
self.dungeonLevel = self.dungeonLevel + 1
if self.dungeonLevel > 4 then self.dungeonLevel = 4 end
TileLock=false
self:createTiles()
...
self.cofloater:runCrawl(self:initialCrawl(self.dungeonLevel))
self:startTimers()
self.playerCanMove = true
TileLock = true
end
function GameRunner:initialCrawl(level)
local co = CombatRound()
local msgs = self:crawlMessages(level)
local result = {}
for i,m in ipairs(msgs) do
table.insert(result, co:display(m))
end
return result
end
The messages are an array of strings, stored by level. So we want to add our new message, if CodeaUnit is defined.
Begin with a test. (You thought I was going to forget, didn’t you?)
I’m going to pass in CodeaUnit to crawlMessages in the above, and provide a second parameter to that function
_:test("No Extra if flag is nil", function()
local msgs = GameRunner:crawlMessages(1,nil)
local m1 = msgs[1]
local found = string.find(m1, "DO YOU")
_:expect(found).is(nil)
local me = msgs[#msgs]
found = string.find(m1, "DO YOU")
_:expect(found).is(nil)
end)
Here, I check the first and last messages for “DO YOU” and expect not to find it. I expect this to run, if my luck is in. And it does. Now:
_:test("Extra if flag is non-nil", function()
local msgs = GameRunner:crawlMessages(1,true)
local m1 = msgs[1]
local found = string.find(m1, "DO YOU")
_:expect(found).is(1)
local me = msgs[#msgs]
found = string.find(m1, "DO YOU")
_:expect(found).is(1)
end)
This should fail nicely.
Feature: Do You Have a Test for That?
2: Extra if flag is non-nil -- Actual: nil, Expected: 1
Now we can code:
function GameRunner:crawlMessages(level)
local msgs = { { "Welcome to the Dungeon.",
"Here you will find great adventure,",
"fearsome monsters, and fantastic rewards.",
"Some of you may die, but I am willing",
"to make that sacrifice."},
{ "This is the fearsome Segundo Level,",
"where the monsters are even more terrible,",
"and the rewards harder to earn.",
"This could be the end for you." },
{ "I am frankly amazed that you have made",
"it this far, as feeble and weak as you are.",
"I suppose you must be commended for luck,",
"if not for skill.",
"I suspect you'll not last much longer."},
{ "You have delved deeper here than we",
"could have imagined. There's no telling what",
"you may find, nor what creatures you may",
"encounter.",
"Prepare to die!"}}
if level > #msgs then level = #msgs end
return msgs[level]
end
I’ll code this nasty, then see about improving it.
function GameRunner:crawlMessages(level, codeaUnit)
local msgs = { { "Welcome to the Dungeon.",
"Here you will find great adventure,",
"fearsome monsters, and fantastic rewards.",
"Some of you may die, but I am willing",
"to make that sacrifice."},
{ "This is the fearsome Segundo Level,",
"where the monsters are even more terrible,",
"and the rewards harder to earn.",
"This could be the end for you." },
{ "I am frankly amazed that you have made",
"it this far, as feeble and weak as you are.",
"I suppose you must be commended for luck,",
"if not for skill.",
"I suspect you'll not last much longer."},
{ "You have delved deeper here than we",
"could have imagined. There's no telling what",
"you may find, nor what creatures you may",
"encounter.",
"Prepare to die!"}}
if level > #msgs then level = #msgs end
local m = msgs[level]
if codeaUnit then
table.insert(m, "DO YOU HAVE A TEST FOR THAT?")
table.insert(m, 1, "DO YOU HAVE A TEST FOR THAT?")
end
return m
end
If the flag is present, i.e. not nil or false, we insert the message at the beginning and end of the list. The test runs.
Now to install the feature:
function GameRunner:initialCrawl(level)
local co = CombatRound()
local msgs = self:crawlMessages(level, CodeaUnit)
local result = {}
for i,m in ipairs(msgs) do
table.insert(result, co:display(m))
end
return result
end
I expect to see the message when I start the game. And I do:
Will this help? I don’t know, but it might. We’ll see.
Now that we’ve worked on our habits a bit, let’s work on the program.
What’s Next?
I have two things in mind. One is to add the notion of poison to the game, so that when the player is bitten by certain creatures, she will be “poisoned” and will lose health points until she hits a health power-up or a healing potion. (Healing potions do not exist yet.)
Relating to this one, I have in mind that power-ups should appear in the player’s character sheet as items and should be used when touched. That’s going to be a bit tricky and I’m definitely not planning it for right away. GUI stuff, I hate it. But we should at least keep that future feature in mind for poison, though I expect it to make little or no difference.
The other idea is larger than poison, but I hope not too large. I want to change monster behavior.
Bryan got me thinking about this in our last Zoom Ensemble meeting. He floated the idea of having no combat in his game. There are, of course, lots of interesting games that include little or no combat. So I was thinking, what if monsters wouldn’t attack unless you got in their face, but they did tend to follow you around?
I envision a monster rule something like this:
- If range to player is large, move randomly;
- If range to player is less than R1, move toward player;
- If range to player is less than R2, move away from player;
- if range to player is 1, attack at will.
I’m thinking R1 is whatever it is now, 10, 15, 20, I don’t know. R2 has to be at least three, because I’d like to be able to drive a monster back by approaching it, without getting into attack range. So if it was allowed to be 2 away, I couldn’t do that.
Let’s do the monster move, it sounds like more fun. Here’s the code now:
function Monster:chooseMove()
-- return true if in range of player
if self:isDead() then return false end
if self:distanceFromPlayer() <= 10 then
self:moveTowardAvatar()
return true
else
self:makeRandomMove()
return false
end
end
It’s clear what we want to do here, no need for a test … hahah you can’t trick me. Let’s try to test both the logic and the new moveAway code that we’ll surely need.
I’m going to work toward a scheme where the chooseMove
begins by figuring out which method to call and then calls it, So it’ll be something like move=selectMove(); do(move)
.
And we can begin with a test for selectMove
. The frame is:
function testMonsterMotion()
CodeaUnit.detailed = false
_:describe("Test Monster Motion", function()
_:before(function()
end)
_:after(function()
end)
_:test("Select makeRandom at long range", function()
end)
end)
end
The test:
_:test("Select makeRandom at long range", function()
local method = Monster:selectMove(15)
_:expect(method).is("makeRandomMove")
end)
I’ve decided to pass in the range, which should mean that selectMove
will work as a class method, eliminating the need to instantiate a Monster. Test will fail, seeking selectMove
:
1: Select makeRandom at long range -- Tests:366: attempt to call a nil value (method 'selectMove')
Now I can start coding the new selectMethod
:
function Monster:selectMove(range)
return "makeRandomMove"
end
Should run, and it does. More test:
_:test("Select moveTowardAvatar range 4-10", function()
local method
method = Monster:selectMove(10)
_:expect(method).is("moveTowardAvatar")
method = Monster:selectMove(4)
_:expect(method).is("moveTowardAvatar")
method = Monster:selectMove(3)
_:expect(method).isnt("moveTowardAvatar")
end)
Writing this tells me that I need to think about the edge behavior, You can imagine a square of tiles around the player, distance 3 on the axes. When we’re near the player, we can move anywhere on that square that’s within reach. So I think we’ll want a new method maintainDistance
or something like that. But for now, to make this work:
2: Select moveTowardAvatar range 4-10 -- Actual: makeRandomMove, Expected: moveTowardAvatar
No surprise here. We code:
function Monster:selectMove(range)
if range > 10 then
return "makeRandomMove"
elseif range <= 4 then
return "moveTowardAvatar"
end
end
That won’t do:
2: Select moveTowardAvatar range 4-10 -- Actual: nil, Expected: moveTowardAvatar
2: Select moveTowardAvatar range 4-10 -- Actual: moveTowardAvatar, Expected: moveTowardAvatar
I need comments in the expects but clearly we have to return something other than nil.
_:test("Select moveTowardAvatar range 4-10", function()
local method
method = Monster:selectMove(10)
_:expect(method, "range 10").is("moveTowardAvatar")
method = Monster:selectMove(4)
_:expect(method, "range 4").is("moveTowardAvatar")
method = Monster:selectMove(3)
_:expect(method, "range 3").isnt("moveTowardAvatar")
end)
I decided to improve the test rather than reason about it.
2: Select moveTowardAvatar range 4-10 range 10 -- Actual: nil, Expected: moveTowardAvatar
2: Select moveTowardAvatar range 4-10 range 3 -- Actual: moveTowardAvatar, Expected: moveTowardAvatar
And the method:
function Monster:selectMove(range) if range > 10 then return “makeRandomMove” elseif range <= 4 then return “moveTowardAvatar” end end
I'm honestly not seeing what's happening here. Oh. <=. Duh,
~~~lua
function Monster:selectMove(range)
if range > 10 then
return "makeRandomMove"
elseif range >= 4 then
return "moveTowardAvatar"
end
end
This is why we test. Test runs. Extend it: I think this is the whole scenario, I felt ready for it:
_:test("Select moveTowardAvatar range 4-10", function()
local method
method = Monster:selectMove(10)
_:expect(method, "range 10").is("moveTowardAvatar")
method = Monster:selectMove(4)
_:expect(method, "range 4").is("moveTowardAvatar")
method = Monster:selectMove(3)
_:expect(method, "range 3").is("maintainRange")
method = Monster:selectMove(2)
_:expect(method, "range 2").is("moveAwayFromAvatar")
method = Monster:selectMove(1)
_:expect(method, "range 1").is("moveTowardAvatar")
end)
This will fail:
2: Select moveTowardAvatar range 4-10 range 3 -- Actual: nil, Expected: maintainRange
function Monster:selectMove(range)
if range > 10 then
return "makeRandomMove"
elseif range >= 4 then
return "moveTowardAvatar"
elseif range == 3 then
return "maintainRange"
end
end
2: Select moveTowardAvatar range 4-10 range 2 -- Actual: nil, Expected: moveAwayFromAvatar
Do you see how the test is telling me, bit by bit, what needs doing next? This is why they’re so powerful and helpful. I’m just ticking along making them work, repeat until done.
function Monster:selectMove(range)
if range > 10 then
return "makeRandomMove"
elseif range >= 4 then
return "moveTowardAvatar"
elseif range == 3 then
return "maintainRange"
elseif range == 2 then
return "moveAwayFromAvatar"
end
end
2: Select moveTowardAvatar range 4-10 range 1 -- Actual: nil, Expected: moveTowardAvatar
function Monster:selectMove(range)
if range > 10 then
return "makeRandomMove"
elseif range >= 4 then
return "moveTowardAvatar"
elseif range == 3 then
return "maintainRange"
elseif range == 2 then
return "moveAwayFromAvatar"
elseif range == 1 then
return "moveTowardAvatar"
end
end
This will run, but I note that zero is a possible distance from the player, although it isn’t supposed to happen, it sometimes does. So I’ll need another test after this one runs, which it does.
_:test("Select moveTowardAvatar range 4-10", function()
local method
method = Monster:selectMove(10)
_:expect(method, "range 10").is("moveTowardAvatar")
method = Monster:selectMove(4)
_:expect(method, "range 4").is("moveTowardAvatar")
method = Monster:selectMove(3)
_:expect(method, "range 3").is("maintainRange")
method = Monster:selectMove(2)
_:expect(method, "range 2").is("moveAwayFromAvatar")
method = Monster:selectMove(1)
_:expect(method, "range 1").is("moveTowardAvatar")
method = Monster:selectMove(0)
_:expect(method, "range 0").is("moveAwayFromAvatar")
end)
I decide to move away if we’re colocated.
2: Select moveTowardAvatar range 4-10 range 0 -- Actual: nil, Expected: moveAwayFromAvatar
I’ll just make that the default, since the code looks like it might need a default, even though it doesn’t. No, on second thought, putting a default in makes it look like it might be incomplete. I’ll do this first:
function Monster:selectMove(range)
if range > 10 then
return "makeRandomMove"
elseif range >= 4 then
return "moveTowardAvatar"
elseif range == 3 then
return "maintainRange"
elseif range == 2 then
return "moveAwayFromAvatar"
elseif range <= 1 then
return "moveTowardAvatar"
end
end
I expect the tests to run now. Silly man, code to your spec:
function Monster:selectMove(range)
if range > 10 then
return "makeRandomMove"
elseif range >= 4 then
return "moveTowardAvatar"
elseif range == 3 then
return "maintainRange"
elseif range == 2 then
return "moveAwayFromAvatar"
elseif range == 1 then
return "moveTowardAvatar"
else
return "moveAwayFromAvatar"
end
end
Now they do run. This is the value of tests: they discover big mistakes and little silly mistakes, and they aren’t even rude about it.
Now we have the select working, and we have two new methods to write, maintainRange
and moveAwayFromAvatar
, and we need to plug in the new method.
Let’s see if we can do tests for these new methods. I anticipate some messy setup but maybe it won’t be too bad.
I don’t seem to have tests for makeRandomMove
or moveTowardAvatar
. Maybe we need them. I want to start with the move away one, which should be reminiscent of moving toward:
function Monster:moveTowardAvatar()
local dxdy = self.runner:playerDirection(self.tile)
if dxdy.x == 0 or dxdy.y == 0 then
self.tile = self.tile:legalNeighbor(self, dxdy)
elseif math.random() < 0.5 then
self.tile = self.tile:legalNeighbor(self,vec2(0,dxdy.y))
else
self.tile = self.tile:legalNeighbor(self,vec2(dxdy.x,0))
end
end
The playerDirection
function returns a vector with 1 or 0 in each of its dimensions, depending on where the avatar is. (It can return 0,0, which I’m going to ignore because it’ll just mean we don’t move. Moving away should be just like this except negating that direction vector.
What do we need to test this? We need a big room, a monster, a player, and some checking. And there’s the random thing, yucch.
I don’t have tests for what I’m about to do. I’m going to see about making this code more testable. I’ll extract the move logic:
function Monster:moveTowardAvatar()
local dxdy = self.runner:playerDirection(self.tile)
local rand = math.random()
local move = self:chooseDxDy(dxdy,rand)
self.tile = self.tile:legalNeighbor(self,move)
end
function Monster:chooseDxDy(dxdy,rand)
if dxdy.x == 0 or dxdy.y == 0 then
return dxdy
elseif rand < 0.5 then
return vec2(0,dxdy.y)
else
return vec2(dxdy.x,0)
end
end
That seems a perfect refactoring and the tests (such as they are for this) still run, and monsters still behave as before (I went and found some).
Now I’m still at a loss what to test here. Let’s refactor further.
Note: I can almost see what I want here, but not quite clearly enough to write the test for it. Let me see if explaining it will help. We have to methods that will be much the same, move toward and move away. They each choose the single option if the direction to the player is 1,0 or 0,1. Otherwise they choose randomly, preferring y if a random number is < 0.5. So there can certainly be a single method at the bottom that takes the final dxdy and fetches that tile. We have that line at the end of our method above. And the rest of the code doesn’t care about the sign of direction.
I can certainly test chooseDxDy
, so I will do that:
_:test("Monster direction choices", function()
local dxdy, move
dxdy = vec2(1,1)
move = Monster:chooseDxDy(dxdy, 0.25)
_:expect(move).is(vec2(0,1))
end)
That runs. Enhance:
_:test("Monster direction choices", function()
local dxdy, move
dxdy = vec2(1,1)
move = Monster:chooseDxDy(dxdy, 0.25)
_:expect(move).is(vec2(0,1))
move = Monster:chooseDxDy(dxdy, 0.75)
_:expect(move).is(vec2(1,0))
dxdy = vec2(0,1)
move = Monster:chooseDxDy(dxdy, 0.75)
_:expect(move).is(vec2(0,1))
dxdy = vec2(1,0)
move = Monster:chooseDxDy(dxdy, 0.25)
_:expect(move).is(vec2(1,0))
end)
That all runs. I’m going to do moveAwayFromAvatar
and then refactor:
function Monster:moveAwayFromAvatar()
local dxdy = -1* self.runner:playerDirection(self.tile)
local rand = math.random()
local move = self:chooseDxDy(dxdy,rand)
self.tile = self.tile:legalNeighbor(self,move)
end
function Monster:moveTowardAvatar()
local dxdy = self.runner:playerDirection(self.tile)
local rand = math.random()
local move = self:chooseDxDy(dxdy,rand)
self.tile = self.tile:legalNeighbor(self,move)
end
Yay, we have duplication. We can remove it:
function Monster:moveAwayFromAvatar()
local dxdy = -1* self.runner:playerDirection(self.tile)
self:moveInDirection(dxdy)
end
function Monster:moveTowardAvatar()
local dxdy = self.runner:playerDirection(self.tile)
self:moveInDirection(dxdy)
end
function Monster:moveInDirection(dxdy)
local rand = math.random()
local move = self:chooseDxDy(dxdy,rand)
self.tile = self.tile:legalNeighbor(self,move)
end
Tests still run, of course, and the monsters still move at me. We haven’t used our new direction selection stuff yet.
Can i legitimately plug it in? Do I have to do a test. I do not. I have improved a bit on that today. I don’t have to be perfect.
Here’s choose move now:
function Monster:chooseMove()
-- return true if in range of player
if self:isDead() then return false end
if self:distanceFromPlayer() <= 10 then
self:moveTowardAvatar()
return true
else
self:makeRandomMove()
return false
end
end
I need to do the return thing, that’s what tells the game to play the scary music and such. Let’s break that out:
function Monster:chooseMove()
-- return true if in range of player
if self:isDead() then return false end
if self:distanceFromPlayer() <= 10 then
self:moveTowardAvatar()
else
self:makeRandomMove()
end
return self:distanceFromPlayer() <= 10
end
Pure refactoring. All is well. Now:
function Monster:chooseMove()
-- return true if in range of player
if self:isDead() then return false end
local range = self:distanceFromPlayer()
local method = self:selectMove(range)
self[method](self)
return range <= 10
end
That self[method](self]
is how you perform a method by name in Lua, Equivalent to Smalltalk perform:
.
We’ll need maintainRange
. I’ll do a harmless one for now:
function Monster:maintainRange()
self:moveAwayFromAvatar()
end
We’ll get a little approach-avoidance thing but it should work. “Should”. Note that I am not as confident as when I have tests. Working without a net makes me nervous.
Nothing for it but to run it and observe.
That works exactly as I expected. The monster approaches and then goes back. It backs away if approached. You can cause it not to move away by backing it against a wall, because it sees no exit in the upward direction, because of how the move selection works: the distance to the player has no y coordinate. We might want to improve that.
But for now, we have a new feature, and it’s rather nice. Commit: monsters come around but do not attack unless cornered or attacked.. Crawl asks DO YOU HAVE TESTS?
(I forgot to commit that feature. Tch tch, still imperfect. Who knew perfection would take this long?)
It’s nearly time for Breakfast With the Beatles to end, let’s sum up.
Summary
I focused at least a bit more on tests, and even put in a feature to remind me. I’d prefer a better prompt and hope that someone suggests one, but it can’t hurt, might help.
Then I worked on the new monster “AI” motion decisions, moving randomly, or toward the player, or away, or attacking, depending on range. This will usually put the decision about combat squarely in the hands of the player. I think there will be exceptions: you could get trapped in a corridor with a monster at each end. That would still leave the decision to the player, but there’s no real choice.
We could imagine further enhancement to this feature. What if, when there’s a battle going on, all nearby monsters get angry and overcome their usual desire to stay away, and instead move in to help their fellow monster who’s being attacked? That could be a sensible thing for monsters to do, and it probably isn’t difficult.
Making Testing Easier
I think we’ve seen some tricks here that make testing a bit easier on our complex objects like Monster. We can break out the random stuff, saving a random number to pass down to a lower function, enabling that function to be deterministic under tests, simply by passing in a known value.
I think this is an example of “things get better when we push everything down as far as possible”. I noticed this morning that we could have checked the motion a but further if we weren’t so quick to fetch a new tile, but instead worked with tile coordinates instead.
Now at first, that seems a bit off. If we pass a raw vector, or xy pair around, that loses abstraction and meaning. But a coordinate pair is in fact a unique identifier for a tile in the game, since the dungeon is an array of tiles. So we could have an object like “CoordinatedTile”, that had its coordinates “right up front” and that could return a new CoordinatedTile, often without any checking, just by doing the obvious arithmetic on its coordinate part.
I think we’d find that to be a better design. And, OMG, it might be an actual use of the Flyweight pattern! I’ve been looking for an occasion to use that hammer1 for decades now!
Be that as it may, we’ve got a few more tests, we have a rather nice refactoring of monster motion decisions, we have a new monster behavior, and we have the crawl naggingm e about tests. Super day. I knew it was going to be a good day, because I am trying Tiny Habits’ “Maui Habit”, which is to say, immediately upon arising, “It’s going to be a great day!”.
So far, so good. See you next time!
-
When folks read the Gang of Four patterns book, Design Patterns, they often see a neat pattern and want to apply it in their code. This seems almost never to be a good idea, though, of course, sometimes it is. See also *Refactoring to Patterns”, by Joshua Kerievsky. But the bad habit happens so often that I like to call it “Small boy with a pattern”, after the famous “Small boy with a hammer” trope. ↩