Dungeon 147
Spoiler:
In today’s article, I think there’s a lot of decent planning, and some valuable thoughts on how to untangle ourselves when the code we’ve written isn’t the code we wish we had written.
But I really wish I didn’t have to publish this article, because after writing those pretty good thoughts, I spent hours trying to put in a tiny little feature, heads down, not writing a word to you, and then finally reverted.
Sometimes the bear bites you, and it happens to most everyone. So you deserve to see it. Today, the bear bites me.
Read on …
First, some planning. Then we consider why it’s hard to test this thing. What will we decide? I don’t know yet. P.S. Turns out to be one of THOSE days.
Sorry, I was being so vague that I slipped into click-bait. I hope you do believe what happens next, when we get there. Right now I don’t know what that might be.
What are some things we might do?
- Move loose loot, including chests and keys, all into Decor and move them to inventory rather than apply them directly. Or move most of it.
- Create some venomous monsters, since we now have an antidote but no poison.
- Make monsters that follow us get bored after a while and stop following. Sometimes it feels like a parade up in this thing.
- Create some more interesting inventory items. Maybe a Monster-repellent potion, or a Staff of Smiting.
- Make things more testable. (I want to talk about that a bit down below.)
- Some old standby ideas, like puzzles and mechanisms.
Or, we might wrap this project up and do something different. If someone had a suggestion that seemed fun …
Making things more testable
As frequent readers know, sometimes I decide not to TDD or test something because it seems too hard to write the test. Now obviously there is a legitimate tradeoff here. Since we do TDD because it helps us develop working code faster, we shouldn’t use TDD when it is going to slow us down. The most obvious kind of slowing down would be when it takes a long time to write a test, longer than it would take to write the code and test and debug it more manually.
Clearly that can happen. It may not happen as often as we think, because often debugging takes a long time. Worse yet, sometimes we do something that breaks that code that isn’t tested, and a defect slips through the crack into production. It’s “develop working code faster”, and if it isn’t working, it isn’t done.
What do we do? Naively I might say “always bite the bullet and write the test”. Learn from it. If it’s hard, too bad. Well, that’s kind of an authoritarian thing to say, so I would not likely say it. Nonetheless, there is learning to be had whenever we write a difficult test, and the investment can pay off if we apply that learning.
What I do is to use the best judgment I have available to me in the moment. Then I try to pay attention to what happens, to see if I feel any regret over the decision. My general report is that I don’t recall ever regretting writing the test, and I remember many times regretting that I didn’t.
And, in all modesty, I’m fairly good at this. If I were less capable, tests would be harder to write, so the choice would lean toward writing even fewer tests. Sliding down that slippery slope we encounter some programmer who has almost no ability to write tests, so they seem infinitely hard, so he never tests. He should regret that, but since he has little experience of the tests helping him, he doesn’t even feel regret. He just doesn’t do very well.
Fortunately, most of us here could write most tests if we wanted to, so we’re on a part of the curve where we can make our best decision, and then adjust our decision making parameters based on what happens.
When a test is hard to write, there is something wrong with the code.
There’s an important message, though, that can get lost here. When a test is hard to write, there is something wrong with the code. I didn’t say “probably”, because the odds are incredibly high that there is something wrong with the code. So, Ron, what’s wrong with the Dungeon code that makes you not want to test it?
What’s wrong with the code?
At a first cut, what makes code hard to test is coupling. Objects connected together, depending on each other, using each other, in ways that are hard to untangle. In the case of the Dungeon program, we have at least these couplings:
- Monsters and Player know the GameRunner object, and they know the tile they are on.
- Tiles know the GameRunner, and in contents, know all the items that are on them.
- Loots, Keys, Chests, and Decor know the tile they are on. Keys and Chests know the GameRunner.
- GameRunner knows the collection of tiles, the Player, the collection of Monsters, and most everything else: it’s a sort of broker for all communication among most of the objects in the game.
The result of this is that to test any of those objects, and many others, we need to set up a legal arrangement of GameRunner, Tiles, and whatever we really wanted to test.
In short, what makes the dungeon hard to test is usually that it requires setting up a bunch of objects just to make the one we want to test willing to operate. The code is too highly coupled.
What can we do about this?
Well, sure, the easy answer is, reduce coupling. The question then becomes, “Yeah, I knew that, but how?”
And there’s an issue, or I think there is. I work very incrementally in these articles. I do design, of course, but mostly I use very localized design thinking, what do I want this object to do, what do I want that one to do. There is an overall design, and it’s not entirely horrible, but I never sat down and drew diagrams of how it was all going to be.
That also means that when the coupling got messy, there was no diagram to look at and think “wow, that’s getting messy”. My decisions were mostly local ones. This guy needs a connection to that guy, because that guy is the one that does this thing that this guy needs. OK, hook them up.
So I got into this mess bit by bit. Isn’t that always the way of it? Bit by bit, we let things get worse. We may not even notice, until one day we look around and realize we are living in a pig sty. And then what?
We have a couple of choices. If we’re living in a pig sty, we can call 1-800-GET-JUNK, and some cleaners, and go through the place like a hurricane, and voila, some time and a lot of money later, we’re living in a lovely home again.
Or we can just deal with it. When we need a dish, wash a dish. When the piles get too high, get some crates and pack them, because they pile up better. That way lies an article a few years from now about this horrible house our dead mouldering body was found in. Or a lot of trouble for our kids.
Or, we can bear down, just a bit. When we need a dish, wash a few. When the piles are too high, pull out some stuff and trash it. Put some other stuff back in the drawer where it belongs. Maybe get some shelving, or a cabinet, and put some things in it. Small steps, moving toward better. If we stick with that, things will get better ever day. We might even feel better every day.
Those are my choices here. I could back off, draw up the design, and then stop delivering features and improve the code for a dozen articles or so. Or I could tell management that we have to rewrite the product. (I’ve done that, and it does not go down well.)
I could just live with it. We haven’t released many bugs, we’re not slowing down much. We just don’t have as many tests as we’d like.
Or, maybe I could improve things bit by bit, moving toward a better design. This seems to be “clearly” the right thing to do, but there’s a bit of a problem: what is this better design we’re moving toward? What are some reasonable small steps toward it.
I see at least two sub-choices for the incremental improvement. One is that I could draw the diagram of the system and look at it, and figure out places to cut connections, maybe new objects to support some operations, maybe split up big objects like GameRunner.
The other thing would be to write tests. When next a test seems hard to write, treat that as an opportunity to figure out why it’s hard to write, and fix things to make it less hard.
Note that this isn’t the same as “use test doubles and fake objects to make testing easier”. In a sense, that’s a cop-out, ignoring the real problem, excess coupling, leaving the real code just as bad as ever. Maybe, instead, we could observe that this test doesn’t really need the GameRunner, and change enough code so that we can get the object under test configured without a GameRunner. We could reduce the coupling that way. Maybe we split off the part of the object that does need the GameRunner from the rest, and test the rest.
There’s no telling what we’d do. We know that we have a nearly unlimited array of things we could do. We mostly need to get ourselves in a position to think about something, and do something about it.
Diagrams
I should mention that there’s no harm in drawing the system diagram, or a bunch of them, and I might do that as well. I focus so tightly on working with real code that I probably do less “big picture” work than is ideal. So if I do draw a picture, I’ll tell you.
In fact, I drew one last night. It’s lovely. Here it is:
In my defense, I drew this on my lap while watching the Queen’s Gambit final episode. Great series, by the way, and with a happy ending. I like happy endings1.
How should this thing work?
Well, I’m glad you asked that. I’d say that the core of the system is that every fraction of a second, we go through all the tiles and draw them. Each tile draws itself, and then draws its contents. There is a special arrangement for the Player, who is drawn across more than one tile, so she is drawn last. That’s one reason why the GameRunner needs to know the Player.
The game proceeds in turns. When it is the monsters’ turn, GameRunner tells each monster to move, and it does. When it is the player’s turn, the buttons and keyboard are directed to the Player. In principle, I guess, the touched
and keyboard
methods, which Codea calls directly, could be placed anywhere, but they are presently placed in Main, and they pass the events to GameRunner, who passes them on to the Buttons and Player as appropriate.
When the Player wants to move, it informs the GameRunner to clear tiles of illumination, then negotiates with its Tile for the move. (There’s a lot behind the scenes there.)
The Tile will need to consider other tiles, for example to determine whether a move to that tile should be allowed. The Tile doesn’t know all the tiles, so it asks the GameRunner to provide any tiles it’s interested in. (We could probably give the tiles a Dungeon object instead of GameRunner, with a little effort. That’s a lighter connection. (We could perhaps link all the tiles together, or let them just know the array of which they are a member.))
Monsters move using one or more strategies, which all come down to selecting a different Tile and moving to it. So they know their Tile
A search shows me that Monsters only have a few references to the GameRunner:
function Monster:flipTowardPlayer()
local dir = self.runner:playerDirection(self.tile)
if dir.x > 0 then
scale(-1,1)
end
end
function Monster:pathComplete()
self.runner:removeMonster(self)
end
function Monster:setTimer(action, time, deltaTime)
if not self.runner then return end
local t = time + math.random()*deltaTime
return tween.delay(t, action, self)
end
function Monster:startActionWithPlayer(aPlayer)
self.runner:initiateCombatBetween(self,aPlayer)
end
Each of these could be removed, but differently.
The startActionWithPlayer
could use some other object to initiate combat, perhaps dealing directly with CombatRound.
The timer-setting code is just trying to decide whether we’re under test. That could be signaled some other way.
I don’t instantely see a better way to get the direction to the player, which is used to make the monster face the player, but we could probably think of something.
Monsters could know their collection and remove themselves directly.
So … with four changes, none of which would be terribly difficult, we could probably disconnect the Monster from the GameRunner.
And so it will go. With or without a “big picture”, we can observe connections that make testing harder and work incrementally to remove them. We’d choose to remove connections that make whatever test we want to write right now easier. If we never get troubled by one of the connections, no harm no foul.
Event Bus
Someone within my hearing mentioned an “Event Bus” recently. The idea, of course, is that there is some way for objects to trigger an “event” when something of interest happens, and any object that cares about the event can subscribe to it. The bus itself, or at least the subscription service, needs to be a well-known object, but since the object itself would be single purpose and pretty light weight, connecting to that can be better than connecting to other more complex objects.
We might keep that notion in mind. Maybe there are things we should subscribe to if we care. We could even make drawing into an event, triggering a draw event every zillionth of a second, and the tiles and princess could subscribe and get drawn. Monsters might subscribe to a “monster turn” event. The monster that wants to die on path complete might just cancel his subscriptions and quietly vanish.
In principle, one could use the bus for communication. One object could trigger an event, another subscribes to it, the bus passes enough info to the subscriber to let it call the original object back. That might get a bit messy. I’d have to have the bus in play a bit to decide whether that was a good thing.
Even just thinking about the idea, I can see uses for it. Drawing, keyboard, and touches might all be removed from GameRunner concern. Adding a message to the Crawl could be a matter of triggering an event, again eliminating a hook to the GameRunner.
More Well-Known Objects
The crawl is an example in another way. It’s basically just a FIFO queue of text, although the present implementation has a bunch of coroutine logic left in. There’s nothing to prevent the crawl from being made a public object to which we send messages.
One doesn’t like to proliferate globals, and the event bus might be better, but if a judicious new global could remove a lot of connections to some too-public object like GameRunner, it might be worth it.
Point is
Point is, we have ideas and techniques available to us. We don’t need to beat ourselves up, but we might want to bear down just a bit on doing the right thing.
Hold me to that. Tweet me up, or Slack me, or email me with feedback, questions, whatever.
Now. Shall we code anything at all?
Do something …
Here’s where I start to go off the rails.
I feel the need to do something to justify my paycheck. Oh, wait, I don’t get a paycheck. Maybe I should just blow this whole gig off. No, it’s kind of fun, and I know one person who kind of likes these articles.
Here’s something. I have a note here that reads “Let Decor deal Lethargy or Weakness”. Right now, Decor can add damage to your health. It would be interesting if a Decor item wafted a horrible scent your way and you became V E R Y T I R E D. Or you started to feel very weak. We do consider relative speed in combat, and we could “easily” consider strength as well.
Let’s see what it would take to make it possible for Decor to damage speed or strength, not just health.
Here’s what we have now:
function Decor:damage(aPlayer)
local co = CombatRound(self,aPlayer)
co:appendText("Ow! Something in there is sharp!")
local damage = math.random(0,aPlayer:health()//2)
co:applyDamage(damage)
self.tile.runner:addToCrawl(co:getCommandList())
end
function CombatRound:applyDamage(damage)
self.defender:accumulateDamage(damage)
local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=nil, arg2=damage }
self:append(op)
self:append(self:display(self.defender:name().." takes "..damage.." damage!"))
if self.defender:willBeDead() then
local op = OP("extern", self.defender, "youAreDead", self)
self:append(op)
local msg = self.defender:name().." is down!"
self:append(self:display(msg))
self.defender:playerCallback(self, "playerIsDead")
end
end
function Entity:accumulateDamage(amount)
self.characterSheet:accumulateDamage(amount)
end
function CharacterSheet:accumulateDamage(damage)
self._health:accumulateDamage(damage)
end
Let’s provide an optional parameter, the kind of damage to accumulate:
function CharacterSheet:accumulateDamage(damage, kind)
local attr = self[kind or "_health"]
attr:accumulateDamage(damage)
end
I’m thinking about tests, but I’m going to go through and pass in the optional variable kind
:
function CombatRound:applyDamage(damage, kind)
self.defender:accumulateDamage(damage, kind)
local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=nil, arg2=damage }
self:append(op)
self:append(self:display(self.defender:name().." takes "..damage.." damage!"))
if self.defender:willBeDead() then
local op = OP("extern", self.defender, "youAreDead", self)
self:append(op)
local msg = self.defender:name().." is down!"
self:append(self:display(msg))
self.defender:playerCallback(self, "playerIsDead")
end
end
function Decor:damage(aPlayer)
local co = CombatRound(self,aPlayer)
co:appendText("Ow! Something in there is sharp!")
local damage = math.random(0,5)
co:applyDamage(damage,"_strength")
self.tile.runner:addToCrawl(co:getCommandList())
end
function Entity:accumulateDamage(amount, kind)
self.characterSheet:accumulateDamage(amount, kind)
end
I think this set of changes means that the dangerous decor will damage strength. Let’s see what happens.
Well, tests fail saying:
6: monster attack -- CombatRound:167: attempt to call a nil value (method 'applyDamage')
Somehow I managed to remove that whole method. I think it happened when I tried to do a Find. Codea sometimes lets you type into a tab when you think you’re typing into the find box.
Working Copy should have the method. Well, it doesn’t. Oh well, it’s up there. Grabbing it.
At least an hour later …
I’ve been trying to debug why, with the code above referring to the entity’s strength, what I see on the screen is that the player’s health declines instead of her strength.
I am delayed by the fact that to test, I add a print here and there and then play the game until I find a Decor that deals damage. Then I step on it to read the debug messages.
I keep pushing the prints down and down. They all print what I expect. The only possibility that I see now is that the attribute sheet that displays player status may be pointing at something other than the attributes in the character sheet. But even that doesn’t explain why health would go down, not strength, no matter what I put in the call.
After way too long, I find this:
function CharacterSheet:applyAccumulatedDamage(damage)
self._health:applyAccumulatedDamage(damage)
end
Now, roughly what is supposed to happen is that a combat round can accumulate damage, called here:
function Entity:damageFrom(aTile,amount)
if not self:isAlive() then return end
self.characterSheet:applyAccumulatedDamage(amount)
if self.characterSheet:isDead() then
sound(self.deathSound, 1, self.pitch)
self:die()
else
sound(self.hurtSound, 1, self.pitch)
end
end
This is called out of CombatRound:
function CombatRound:applyDamage(damage, kind)
self.defender:accumulateDamage(damage, kind)
local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=nil, arg2=damage }
self:append(op)
self:append(self:display(self.defender:name().." takes "..damage.." damage!"))
if self.defender:willBeDead() then
local op = OP("extern", self.defender, "youAreDead", self)
self:append(op)
local msg = self.defender:name().." is down!"
self:append(self:display(msg))
self.defender:playerCallback(self, "playerIsDead")
end
end
I think the point of this rigmarole is to defer the actual damage from displaying until the information hits the crawl. At this point in the morning (now 12:13PM), I’m not sure of my own name. In any case, though we tried to apply kind in the initial call, and we may even have accumulated it correctly, we don’t unwind it correctly, because the damageFrom
assumes health, not kind.
That’s the bug. I am whipped. Not going to try to fix it. Going to revert and do it again next time.
I’m so tempted not to revert, to defer shipping for today, and bull forward later today or tomorrow. That’s surely a bad idea.
I could commit and then roll back to the previous commit, I suppose. Or create a branch, which I never do, to the point where I don’t even know how to do it.
No, just revert.
That’s a wrap. Let me try to sum up.
Summary
Well, sometimes the bear bites you. I feel badly for spending so much time digging in this rathole, but there is a lesson here as well.
CombatRound
is very tricky. Remember that we used to have a coroutine handling combat, being pulled on by the crawl. Then we went to this proactive CombatRound, which applies damage into accumulators, and predicts whether the monster or player will be killed and conditions future combat on that prediction.
It seems to mostly work. It seems often to confuse me.
Today, in my zeal to slap in what seemed like a simple change, I missed a spot where the change needs to go, namely in the unwinding of accumulated damage, which will need to remember what attribute was damaged. (Another possibility is not to use CombatRound for the damage from Decor. That might be doable. It might also lead to odd timing problems. It would surely lead to some confusion, if today is any indication.)
The big mistake today was to burn so much time trying to figure out what was happening. Maybe if I had written a test it would have gone better. My test cycles would surely have gone faster since it probably took me a minute or two to perform my in-world tests, and TDD tests would take moments.
Next time, I’ll TDD this feature. Today, I’ll take my lumps.
I hope you’ll come back. Wouldn’t blame you if you didn’t. But it’s going to get interesting. I promise. At least interesting for me.
-
Get your mind out of the gutter. ↩