Dungeon 112
Today we’ll follow up an interesting idea from an idea source that is new to me. I expect that we’ll have an interesting and desirable outcome. Interesting, at least.
A few days back, I posted the Entity hierarchy on a Slack group that I belong to, asking, in essence, what’s so bad about this inheritance. No one really answered that but I got a fascinating suggestion from Beth:
I might see both the Princess and Monster classes having a Health class that they delegate taking damage to, which would end up with most of the shared logic. That would make sense if, for example, there was an effect that caused someone to take more or less damage, or if different monsters took different damage from different attack types.
Similarly, they might each have an Attack property, which could include the behavior of picking between the attack phrases. That would make something like “having different weapons” easy to implement.
But I think until you know which of those functions you might want to implement first, living with the One Big Class is fine.
I found the idea interesting but not compelling right away. I wasn’t sure whether the code really wants to be that way. But as I reflect further, I think there are signs. We have duplication, and we have feature envy, and we have primitive obsession. You’d think that even this blind pig would have found those acorns by now.
In my defense, I can only say that there are much worse problems in this code than what we’ll be looking at here. Now that I think about it, that’s not much of a defense, is it. But the fact is, I am not my code, and I feel no need to defend it, as code is a fact and needs no defense, and since I’m not the code, I’m not attacked, and need no defense either.
However, when we do find areas in our code that rather obviously could benefit from some improvement, there’s value to thinking about what in our habits may have allowed that particular thing to have happened. Often, it’s just “out of sight out of mind”, but sometimes, perhaps even usually, we did pick up on the clues, and then moved on, usually in aid of “going faster” and “we’ll clean that up later”. Now, if something’s going to be left alone in imperfect form, we may really “go faster” by leaving it in its present working but not lovely state. But “we’ll clean it up later”? That day rarely comes.
But there’s another day that often comes, the day when that not quite as good as it might be code comes up for attention. A story impacts that code. And that’s the day when we can “clean it up later”. See Refactoring - Not on the backlog! for some thoughts on that.
Signs and Portents
There were signs and portents about this code, whisperings on the wind, grumblings down in the deeper levels of the dungeon. To see what I mean, let’s look at the handling of Health.
function Entity:die()
self.healthPoints = 0
self.accumulatedDamage = 0
self.alive = false
end
function Entity:damageFrom(aTile,amount)
if not self:isAlive() then return end
self.healthPoints = self.healthPoints - amount
self.accumulatedDamage = self.accumulatedDamage - amount
if self.accumulatedDamage < 0 then self.accumulatedDamage = 0 end
if self.healthPoints <= 0 then
self.runner:addTextToCrawl(self:name().." is dead!")
sound(self.deathSound, 1, self.pitch)
self:die()
else
sound(self.hurtSound, 1, self.pitch)
end
end
function Entity:willBeDead()
return self:isDead() or self.accumulatedDamage >= self.healthPoints
end
Here we see primitive obsession, since healthPoints
and accumulatedDamage
are just numbers. We see feature envy, just vaguely, such as in this pair of lines:
self.healthPoints = self.healthPoints - amount
self.accumulatedDamage = self.accumulatedDamage - amount
These lines must occur together (coupling!). They’re moving some amount
of damage from the accumulated damage pile and subtracting it from health. That’s an idea. It’s not expressed very well in the code.
We also have duplication, such as this:
function Entity:health()
return self.healthPoints
end
function Entity:speed()
return self.speedPoints
end
function Entity:strength()
return self.strengthPoints
end
And more feature envy:
function Entity:speedRoll()
return math.random(1,self:speed())
end
Now, my faculties may be fading but I think these signs and portents are still pretty weak signal in a lot of noise. When these are the worst problems in my program, I’m going to be pretty happy. But today, we have some requirement that cause us to look more deeply at this code.
Stories
Careful readers may recall that there used to be a “healthMax” value, less than 20, representing an entities maximum possible health, and similarly for the other attributes.
(Note that “other attributes” is another sign, or maybe portent, telling us that there is an idea here that is not well expressed.)
My intention when that value existed was that as a player advanced, her ability to have more health, more strength, and so on, would increase. This would be part of leveling up. I removed that capability at the time I created the attribute sheet, mostly because I didn’t like the way the sheet looked, not what you’d call a great reason. On the other hand, the feature was pretty much unused, so deleting the code made some sense.
It also hid an important fact, which was that at that time there were two variables relating to e.g. health, health, and healthMax. And a third, magical value, 20, the max max. When we have related values, they are signals that an object is waiting to be born.
But the stories. Here are some capabilities I want in the system.
- Power-ups
- Change the power-ups we find to be temporary. Maybe they add 8 points to your strength, but that value slowly declines back down to your strength’s current maximum.
- Healing
- Change things so that while you’re just wandering, you slowly heal, your health growing back up to its current maximum.
- Poison
- Venomous creatures can poison the player, or otherwise cause their health, strength, and speed to decline over time.
- Leveling up
- Something, I’m not sure what, causes you to level up. When that happens, some amount is added to some or all of your attributes and their maxima.
- More attributes / inventory
- You might find a +2 Sword of Slashing, adding a +2 to the effect of all your attacks. This is a strength kicker. Your own strength hasn’t leveled up. You might find an anti-venom injector. When you’re bitten, you might apply that, or it might be automatically applied. You might find a +3 Shield of Impressive Fortitude, which subtracts 3 from the strength of some or all attacks against you. And so on
Of these ideas, the story we’ll start with is this:
The player has a maximum health, at the beginning of the game. She starts with health at that maximum. When she finds a heart (health) power-up, it adds to her current health, but then declines at some rate down to her current maximum. Similarly, when you’re just wandering, if health is low, it grows slowly back to the maximum.
Even this story can be split, and we will in fact split it as we go forward. But I think we understand the ideas, and we trust ourselves to do them bit by bit.
A Bit of Design
Let’s think a bit about what we’re going to do here. Based on Beth’s idea, we’re going to create a Health object. I foresee that there will be a Strength object and a Speed object, so that this thing will grow up into something more general, like an Attribute or Aspect, but we’re not into generalizing before we have cases, so let’s TDD up a HealthAttribute.
As is my present habit, I’m going to develop the tests and the code in the same Codea tab, because proliferation of tabs is harshing my mellow.
I don’t know yet what this thing has to do, so I’ll write the first part of the test, driving out the class shell:
_:test("HealthAttribute", function()
local ha = HealthAttribute()
_:expect(ha).isnt(nil)
end)
Not much of a test, but I’m just trying to come up to speed.
1: HealthAttribute -- Health:16: attempt to call a nil value (global 'HealthAttribute')
Not very surprising. This is sufficient to make it run:
HealthAttribute = class()
Whee. OK, what do we know? We know that it has a current value and a maximum value. And we know it’s going to do that accumulation thing. We’ll look at our real code to see what makes a decent protocol. In the end, we want to defer all kinds of things down to this attribute or to a collection of them, but it’ll take us a while to get there.
I think it takes a value and a maximum:
_:test("HealthAttribute", function()
local ha = HealthAttribute(5,10)
_:expect(ha:value()).is(5)
_:expect(ha:max()).is(10)
end)
Not thrilling but it’s a start.
1: HealthAttribute -- Health:17: attempt to call a nil value (method 'value')
And …
function HealthAttribute:init(value, max)
self._value = value
self._max = max
end
function HealthAttribute:value()
return self._value
end
function HealthAttribute:max()
return self._max
end
I’m trying a new technique that I read in a book somewhere, starting private names with underbar. It should at least help with the fact that a nice name like value
can’t appear both as a member variable and a method, since there’s only room for one key of a given name in a class.
I expect the test to run. And it does.
Now we’d better look at current usage and see where we want this object to go.
Monsters init like this:
...
self.attackVerbs = self.mtEntry.attackVerbs or {"strikes at"}
self.healthPoints = self:roll(self.mtEntry.health)
self.strengthPoints = self:roll(self.mtEntry.strength)
self.speedPoints = self:roll(self.mtEntry.speed)
...
And their health and such are basically the same as the player’s. But the player goes like this:
function Player:init(tile, runner)
self.runner = runner
self:initTile(tile)
self:initAttributes(self:retainedAttributes())
self:initSounds()
self:initVerbs()
end
function Player:retainedAttributes()
return { keys=0, healthPoints = 12, speedPoints = 8, strengthPoints = 10 }
end
function Player:initAttributes(attrs)
self.alive = true
self.attributeSheet = AttributeSheet(self,750)
self.accumulatedDamage = 0
self:initRetainedAttributes(attrs)
end
function Player:initRetainedAttributes(attrs)
for k,v in pairs(self:retainedAttributes()) do
self[k] = attrs[k]
end
end
There’s some weirdness in player creation. For example:
function GameRunner:placePlayerInRoom1()
local r1 = self.rooms[1]
local tile = r1:centerTile()
self.player = Player:cloneFrom(self.player, tile,self)
end
When we create a new level, we create a new player by cloning the one we already have. Except that we don’t always have one:
function Player:cloneFrom(oldPlayer, tile, runner)
local player = Player(tile,runner)
if oldPlayer then player:initAttributes(oldPlayer) end
return player
end
So we create the new one, and if we have an old one, we copy the attributes from it. If we don’t have an old one, we have those initial attributes.
So it would appear that at the level of creation, if we said something like this, we’d be OK:
function Player:retainedAttributes()
return { keys=0, healthPoints = HealthAttribute(12,12), speedPoints = 8, strengthPoints = 10 }
end
Of course we’d then have a HealthAttribute where we expect to have a number, so perhaps we’ll work toward this:
function Player:retainedAttributes()
return { keys=0, health = HealthAttribute(12,12), speedPoints = 8, strengthPoints = 10 }
end
Saving the attribute in a thing called health makes sense. Or maybe we’ll even use _health. We’ll see how far this new habit goes. I’m not likely to convert everything, and there are issues of consistency. But for now, we’re just sorting out the health thingie.
Let’s see those accesses to healthPoints.
function Entity:die()
self.healthPoints = 0
self.accumulatedDamage = 0
self.alive = false
end
That one is interesting. Is “alive” a property of health or not? The other two are clear. What else?
This next one is the damage taker that is called from the crawl. Damage has been accumulated in the accumulated damage variable, and we move it here to the creature, which makes its noises and such, updates the value so that it’ll show in the attribute sheet, and finally enqueues a message about being dead if one is found to be dead.
function Entity:damageFrom(aTile,amount)
if not self:isAlive() then return end
self.healthPoints = self.healthPoints - amount
self.accumulatedDamage = self.accumulatedDamage - amount
if self.accumulatedDamage < 0 then self.accumulatedDamage = 0 end
if self.healthPoints <= 0 then
self.runner:addTextToCrawl(self:name().." is dead!")
sound(self.deathSound, 1, self.pitch)
self:die()
else
sound(self.hurtSound, 1, self.pitch)
end
end
Here’s an accessor. We’ll have to see who’s doing what, but we can clearly just forward it:
function Entity:health()
return self.healthPoints
end
Here’s a cute one:
function Entity:willBeDead()
return self:isDead() or self.accumulatedDamage >= self.healthPoints
end
This is used in Combat, because during the combat round we need to make decisions about whether the opponent will be dead when the system gets around to doing the damageFrom
. This keeps the visuals and sounds in sync with the crawl, while a combat round has ended long ago in computer years.
function Monster:selectDamageTint()
if self.healthPoints <= 2 then
tint(255,0,0)
else
tint(255,255,0)
end
end
Creatures are supposed to change color when damaged. I’m not sure if that’s still working or not. This can forward one way or another.
function Player:pointsTable(kind)
local t = {Strength="strengthPoints", Health="healthPoints", Speed="speedPoints"}
return t[kind]
end
My, we find interesting things here. This is used by the power-ups:
function Player:addPoints(kind, amount)
local attr = self:pointsTable(kind)
if attr then
local current = self[attr]
self[attr] = math.min(20,current + amount)
self:doCrawl(kind, amount)
end
end
Forwarding will work for that when the time comes. Clearly calls for a feature though:
_:test("HealthAttribute", function()
local ha = HealthAttribute(5,10)
_:expect(ha:value()).is(5)
_:expect(ha:max()).is(10)
ha:addPoints(4)
_:expect(ha:value()).is(9)
end)
And …
function HealthAttribute:addPoints(points)
self._value = self._value + points
end
But what if we add a lot of points? For sure we want them to max at 20. Magic number? I’m not sure.
_:test("HealthAttribute", function()
local ha = HealthAttribute(5,10)
_:expect(ha:value()).is(5)
_:expect(ha:max()).is(10)
ha:addPoints(4)
_:expect(ha:value()).is(9)
ha:addPoints(14)
_:expect(ha:value()).is(20)
end)
This fails, as intended:
1: HealthAttribute -- Actual: 23, Expected: 20
And:
function HealthAttribute:addPoints(points)
self._value = math.min(self._value + points, 20)
end
Tests green. What else?
function Player:selectDamageTint()
if self.healthPoints <= 2 then
tint(255,0,0)
else
tint(255,255,0)
end
end
That looks familiar, doesn’t it? We could push that up into Entity. And, because that’s the kind of person we are, we will:
I’m still not sure that feature is working but at least it’s not working in one place now.
Now let’s look at references to accumulatedDamage
:
function Entity:accumulateDamage(amount)
self.accumulatedDamage = self.accumulatedDamage + amount
end
I think we want the HealthAttribute to deal with this internally, so we need some testing.
_:test("HealthAttribute accumulated damage", function()
local ha <const> = HealthAttribute(5,10)
_:expect(ha._accumulatedDamage).is(0)
ha:accumulateDamage(7)
_:expect(ha._accumulatedDamage).is(7)
end)
I used the <const> feature for practice. Changed the other test too. I’m assuming, for now, that no one outside is going to need to know about accumulated damage, so my test will check the private. Coupling, but better that than providing a method, I think. I could be wrong.
This test drives out:
function HealthAttribute:init(value, max)
self._value = value
self._max = max
self._accumulatedDamage = 0
end
function HealthAttribute:accumulateDamage(damage)
self._accumulatedDamage = self._accumulatedDamage + damage
end
At this point, I want to see what happens when I plug this baby in. I can commit the class and tests first: Initial HealthAttribute. refactor damage tint upward.
I probably should do that in two commits. Deal with it.
I’m just going to slam the new class in here, and follow my nose:
function Player:retainedAttributes()
return { keys=0, health = HealthAttribute(12,12), speedPoints = 8, strengthPoints = 10 }
end
I’ve renamed the member variable to _health, just to see how I feel about that, and now I’ll look for the methods accessing healthPoints again, to fix them up.
Ha!!! Right away I hit this one:
function Entity:die()
self.healthPoints = 0
self.accumulatedDamage = 0
self.alive = false
end
Remember my talking about whether inheriting concrete methods from Entity into Monster and Player was righteous or not? The arguments against are arguments about what happens when things change. We’re faced with a bit of a dilemma here. We can start moving things down from Entity into Player, or we can bite the bullet and change Monsters to use the HealthAttribute right now. I’m going that way. I have a revert button right here.
In Monster:init
, there’s this line:
self.healthPoints = self:roll(self.mtEntry.health)
I’ll change that to this:
self._health = HealthAttribute(self:roll(self.mtEntry.health))
This call does not include the second parameter to HealthAttribute. We can leave it nil, or do something more robust. I choose robust.
_:test("HealthAttribute max defaults", function()
local ha <const> = HealthAttribute(7)
_:expect(ha:max()).is(7)
end)
That fails, driving:
function HealthAttribute:init(value, max)
self._value = value
self._max = max or value
self._accumulatedDamage = 0
end
Interesting Lua fact: in Lua, zero is true. Only
nil
andfalse
are false.
Tests are green but the program explodes. Kind of expected, we need to go through references to healthPoints
.
function Entity:die()
self.healthPoints = 0
self.accumulatedDamage = 0
self.alive = false
end
I think we want this method in HealthAttribute:
function Entity:die()
self._health:die()
self.alive = false
end
And, OK, a test:
_:test("HealthAttribute dies", function()
local ha <const> = HealthAttribute(11,14)
ha:accumulateDamage(4)
ha:die()
_:expect(ha:value()).is(0)
_:expect(ha._accumulatedDamage).is(0)
end)
And :
function HealthAttribute:die()
self._value = 0
self._accumulatedDamage = 0
end
I’m starting to wonder about those accessor methods. We may not need them at all. We’ll see, but if we’re going to use the new _private
notion, we can use that in tests and have no accessors. Some simple searches can quickly find folks delving into the privates of others.
I expect the tests to be green. Yes. Moving right along:
function Entity:damageFrom(aTile,amount)
if not self:isAlive() then return end
self.healthPoints = self.healthPoints - amount
self.accumulatedDamage = self.accumulatedDamage - amount
if self.accumulatedDamage < 0 then self.accumulatedDamage = 0 end
if self.healthPoints <= 0 then
self.runner:addTextToCrawl(self:name().." is dead!")
sound(self.deathSound, 1, self.pitch)
self:die()
else
sound(self.hurtSound, 1, self.pitch)
end
end
This stuff here is about applying accumulated damage, so we want to say this for the first bit:
function Entity:damageFrom(aTile,amount)
if not self:isAlive() then return end
self._health:applyAccumulatedDamage(amount)
if self.healthPoints <= 0 then
self.runner:addTextToCrawl(self:name().." is dead!")
sound(self.deathSound, 1, self.pitch)
self:die()
else
sound(self.hurtSound, 1, self.pitch)
end
end
We require another test, and then I want to scream.
_:test("HealthAttribute applies damage and dies", function()
local ha <const> = HealthAttribute(11,14)
ha:accumulateDamage(4)
ha:applyAccumulatedDamage(1)
_:expect(ha:value()).is(10)
_:expect(ha._accumulatedDamage).is(3)
ha:die()
_:expect(ha:value()).is(0)
_:expect(ha._accumulatedDamage).is(0)
end)
I extended the existing one and adjusted its name.
4: HealthAttribute applies damage and dies -- Health:40: attempt to call a nil value (method 'applyAccumulatedDamage')
As expected. We code:
function HealthAttribute:applyAccumulatedDamage(amount)
self._value = math.max(0, self._value - amount )
self._accumulatedDamage = math.max(0, self._accumulatedDamage - amount)
end
Our new friend Beth is famous for saying “Do you have a test for that”, and I don’t have a test for those math.min
calls there. I’ll do that and then I want to scream.
_:test("HealthAttributes lower limits", function()
local ha <const> = HealthAttribute(10)
ha:accumulateDamage(6)
ha:accumulateDamage(7)
ha:applyAccumulatedDamage(6)
ha:applyAccumulatedDamage(7)
_:expect(ha:value()).is(0)
_:expect(ha._accumulatedDamage).is(0)
end)
This passes, of course, because my speculative math,min
calls accomplish what’s shown in that test. The test also shows how the values could go negative. In principle, some ind of double attack could accumulate damage beyond the limitations of the entity under attack. So we show two attacks, and those attacks being “unwound” properly.
Subtle. Perhaps too subtle, but it’s all I’ve got just now.
Back to looking at .healthpoints
references. This is dragging on too long but I don’t see a more incremental way to do. And the writing and testing is … oh, right, I wanted to scream.
Writing these tests is unquestionably slowing me down, by which I mean I feel certain that it’s slowing me down. It may or may not be. So far, I’ve not noticed them helping find problems, but they’ve certainly put things into my mind that I might not have otherwise considered, so they may be helping unnoticed. Still, I’m finding it draggy to write these tests. I’m writing them because I started with them, and now it only makes sense that if I add capability to HealthAttribute, I should reflect it in the tests, and drive it where possible.
But it makes me want to scream, or at least grumble, because it feels slow. There, I feel better now.
Moving right along … we still have this:
function Entity:damageFrom(aTile,amount)
if not self:isAlive() then return end
self._health:applyAccumulatedDamage(amount)
if self.healthPoints <= 0 then
self.runner:addTextToCrawl(self:name().." is dead!")
sound(self.deathSound, 1, self.pitch)
self:die()
else
sound(self.hurtSound, 1, self.pitch)
end
end
I think we should ask the _health
not what its points are, but whether the entity is dead.
function Entity:damageFrom(aTile,amount)
if not self:isAlive() then return end
self._health:applyAccumulatedDamage(amount)
if self._health:isDead() then
self.runner:addTextToCrawl(self:name().." is dead!")
sound(self.deathSound, 1, self.pitch)
self:die()
else
sound(self.hurtSound, 1, self.pitch)
end
end
And we test first:
_:test("HealthAttributes lower limits", function()
local ha <const> = HealthAttribute(10)
ha:accumulateDamage(6)
ha:accumulateDamage(7)
ha:applyAccumulatedDamage(6)
ha:applyAccumulatedDamage(7)
_:expect(ha:isDead()).is(true)
_:expect(ha:value()).is(0)
_:expect(ha._accumulatedDamage).is(0)
end)
5: HealthAttributes lower limits -- Health:54: attempt to call a nil value (method 'isDead')
And …
function HealthAttribute:isDead()
return self._value == 0
end
Tests are green and the princess’s attribute sheet popped up, not quite right. We’re still hunting for references to healthPoints
.
function Entity:health()
return self.healthPoints
end
I’m going to allow this one in aid of moving forward:
function Entity:health()
return self._health:value()
end
What about this:
function Entity:selectDamageTint()
if self.healthPoints <= 2 then
tint(255,0,0)
else
tint(255,255,0)
end
end
I think this is righteous where it is, but it should call health
:
function Entity:selectDamageTint()
if self:health() <= 2 then
tint(255,0,0)
else
tint(255,255,0)
end
end
function Entity:willBeDead()
return self:isDead() or self.accumulatedDamage >= self.healthPoints
end
Piffle. Ask the health. We test:
_:test("HealthAttributes lower limits", function()
local ha <const> = HealthAttribute(10)
ha:accumulateDamage(6)
ha:accumulateDamage(7)
_:expect(ha:willBeDead()).is(true)
_:expect(ha:isDead()).is(false)
ha:applyAccumulatedDamage(6)
ha:applyAccumulatedDamage(7)
_:expect(ha:isDead()).is(true)
_:expect(ha:value()).is(0)
_:expect(ha._accumulatedDamage).is(0)
end)
5: HealthAttributes lower limits -- Health:52: attempt to call a nil value (method 'willBeDead')
I think this will do:
function HealthAttribute:willBeDead()
return self._value - self._accumulatedDamage <= 0
end
Tests are green, use the function:
function Entity:willBeDead()
return self._health:willBeDead()
end
Just one more and it’s going to be tricky:
function Player:addPoints(kind, amount)
local attr = self:pointsTable(kind)
if attr then
local current = self[attr]
self[attr] = math.min(20,current + amount)
self:doCrawl(kind, amount)
end
end
function Player:pointsTable(kind)
local t = {Strength="strengthPoints", Health="healthPoints", Speed="speedPoints"}
return t[kind]
end
We’ve cleverly allowed folks to call a central method to add points. This is done here:
function FakePlayer:addPoints(kind, amount)
_:expect(kind).is("Health")
_:expect(amount).is(5)
end
function Loot:actionWith(aPlayer)
self.tile:removeContents(self)
aPlayer:addPoints(self.kind, math.random(self.min, self.max))
end
I think we’re just going to have to handle health separately for now:
function Player:addPoints(kind, amount)
if kind == "Health" then
self._health:addPoints(amount)
else
local attr = self:pointsTable(kind)
if attr then
local current = self[attr]
self[attr] = math.min(20,current + amount)
self:doCrawl(kind, amount)
end
end
end
And I’ll remove the Health from here:
function Player:pointsTable(kind)
local t = {Strength="strengthPoints", Speed="speedPoints"}
return t[kind]
end
I think we can clean that up if and when we attribute the other values. I rather expect to be done now.
Something odd happened in this video:
If you follow the narration, the ghost attacks after it is marked dead. I think I know what that is. We have the alive
flag and the isDead
method. Maybe isAlive
too. That all needs to refer to the HealthAttribute now.
function Entity:die()
self._health:die()
self.alive = false
end
Remove the ref to alive
.
function Entity:isAlive()
return self.alive
end
function Entity:isDead()
return not self:isAlive()
end
Refer to HA:
function Entity:isAlive()
return self._health:isAlive()
end
function Entity:isDead()
return self._health:isDead()
end
That requires a test, if you’re gonna be a fanatic about it.
_:test("HealthAttributes lower limits", function()
local ha <const> = HealthAttribute(10)
ha:accumulateDamage(6)
ha:accumulateDamage(7)
_:expect(ha:willBeDead()).is(true)
_:expect(ha:isDead()).is(false)
_:expect(ha:isAlive()).is(true)
ha:applyAccumulatedDamage(6)
ha:applyAccumulatedDamage(7)
_:expect(ha:isDead()).is(true)
_:expect(ha:isAlive()).is(false)
_:expect(ha:value()).is(0)
_:expect(ha._accumulatedDamage).is(0)
end)
function HealthAttribute:isAlive()
return not self:isDead()
end
function HealthAttribute:isDead()
return self._value == 0
end
I noticed that isDead
is formally correct but arguably unsafe. It assumes an invariant1: _value >= 0
. I think we enforce that, but it’s kind of by the way. We could bullet-proof with <=
here, or we could do something about the invariant.
Now at first blush, the only subtraction in the HealthAttribute
class is protected:
function HealthAttribute:applyAccumulatedDamage(amount)
self._value = math.max(0, self._value - amount )
self._accumulatedDamage = math.max(0, self._accumulatedDamage - amount)
end
But, in principle, someone could add negative points here:
function HealthAttribute:addPoints(points)
self._value = math.min(self._value + points, 20)
end
If they did, they could drive _value
negative. And who knows, there might be a time when we damage someone that way. OK, in for a penny, let’s write a test.
_:test("HealthAttributes limits", function()
local ha <const> = HealthAttribute(10)
ha:accumulateDamage(6)
ha:accumulateDamage(7)
_:expect(ha:willBeDead()).is(true)
_:expect(ha:isDead()).is(false)
_:expect(ha:isAlive()).is(true)
ha:applyAccumulatedDamage(6)
ha:applyAccumulatedDamage(7)
_:expect(ha:isDead()).is(true)
_:expect(ha:isAlive()).is(false)
_:expect(ha:value()).is(0)
_:expect(ha._accumulatedDamage).is(0)
ha:addPoints(-5)
_:expect(ha:value()).is(0)
end)
5: HealthAttributes limits -- Actual: -5, Expected: 0
And …
function HealthAttribute:addPoints(points)
self._value = math.max(0,math.min(self._value + points, 20))
end
Test runs.
Back to looking for the .alive
flag.
function Monster:init(tile, runner, mtEntry)
if not MT then self:initMonsterTable() end
self.alive = true
self.tile = tile
...
Remove that.
function Monster:chooseAnimation()
if self.alive then
self.movingIndex = self.movingIndex + 1
if self.movingIndex > #self.moving then self.movingIndex = 1 end
self:setAnimationTimer()
else
self.sprite1 = self.dead
end
end
Replace that:
function Monster:chooseAnimation()
if self:isAlive() then
self.movingIndex = self.movingIndex + 1
if self.movingIndex > #self.moving then self.movingIndex = 1 end
self:setAnimationTimer()
else
self.sprite1 = self.dead
end
end
Should have been that way all along.
function Monster:chooseMove()
-- return true if in range of player
if not self.alive then return false end
if self:distanceFromPlayer() <= 10 then
self:moveTowardAvatar()
return true
else
self:makeRandomMove()
return false
end
end
function Monster:chooseMove()
-- return true if in range of player
if not self:isDead() then return false end
if self:distanceFromPlayer() <= 10 then
self:moveTowardAvatar()
return true
else
self:makeRandomMove()
return false
end
end
Further fixes:
function Monster:photo()
if self:isAlive() then
return self.moving[1]
else
return self.dead
end
end
function Player:initAttributes(attrs)
self.alive = true
self.attributeSheet = AttributeSheet(self,750)
self.accumulatedDamage = 0
self:initRetainedAttributes(attrs)
end
Remove that ref.
function Player:drawExplicit(tiny)
local sx,sy
local dx = 0
local dy = 30
pushMatrix()
pushStyle()
spriteMode(CENTER)
local center = self:graphicCenter() + vec2(dx,dy)
translate(center.x,center.y)
if not self.alive then tint(0) end
sx,sy = self:setForMap(tiny)
self:drawSprite(sx,sy)
popStyle()
popMatrix()
if not tiny then
self.attributeSheet:draw()
end
end
Becomes:
function Player:drawExplicit(tiny)
local sx,sy
local dx = 0
local dy = 30
pushMatrix()
pushStyle()
spriteMode(CENTER)
local center = self:graphicCenter() + vec2(dx,dy)
translate(center.x,center.y)
if self:isDead() then tint(0) end
sx,sy = self:setForMap(tiny)
self:drawSprite(sx,sy)
popStyle()
popMatrix()
if not tiny then
self.attributeSheet:draw()
end
end
The other references to .alive
are in FakeEntity
and I think they are OK. It’s a Fake. I expect things to work. I’m also wondering why I didn’t commit before.
I find an interesting: defect the dead monster follows me around. Somewhere we missed something that kept it from moving when dead.
Ha, tiny fool:
function Monster:chooseMove()
-- return true if in range of player
if not self:isDead() then return false end
if self:distanceFromPlayer() <= 10 then
self:moveTowardAvatar()
return true
else
self:makeRandomMove()
return false
end
end
I converted to isDead
but didn’t remove the not.
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 “should” have a test that would have detected that. But I haven’t. Let’s see how things go now:
Sheesh! I notice that my power-ups aren’t hitting the crawl. What’s up with that? “The changes I made couldn’t have affected that!”2
We did change the loots but where was the crawl stuff and where is it now?
function Player:addPoints(kind, amount)
if kind == "Health" then
self._health:addPoints(amount)
else
local attr = self:pointsTable(kind)
if attr then
local current = self[attr]
self[attr] = math.min(20,current + amount)
self:doCrawl(kind, amount)
end
end
end
Ah. We don’t doCrawl
on Health.
function Player:addPoints(kind, amount)
if kind == "Health" then
self._health:addPoints(amount)
else
local attr = self:pointsTable(kind)
if attr then
local current = self[attr]
self[attr] = math.min(20,current + amount)
end
end
self:doCrawl(kind, amount)
end
Yes, that’s all better now. Let’s commit: Health concept now uses HealthAttribute throughout.
But you know what? I wonder if the cloning works. Let’s look:
function Player:cloneFrom(oldPlayer, tile, runner)
local player = Player(tile,runner)
if oldPlayer then player:initAttributes(oldPlayer) end
return player
end
function Player:retainedAttributes()
return { keys=0, _health = HealthAttribute(12,12), speedPoints = 8, strengthPoints = 10 }
end
function Player:initAttributes(attrs)
self.attributeSheet = AttributeSheet(self,750)
self.accumulatedDamage = 0
self:initRetainedAttributes(attrs)
end
function Player:initRetainedAttributes(attrs)
for k,v in pairs(self:retainedAttributes()) do
self[k] = attrs[k]
end
end
Yes, that’s going to work. But let’s write a test for that anyway.
No. It’s too hard to set up a decent Player, at least this morning. I’ll make a sticky note for it.
In manual testing to be sure that cloning works, which I hate because it requires me to find the WayDown, and I’m likely to get killed on the way, I notice that I’m still seeing attacks after a monster is dead.
Where do monsters attack?
I think it all comes down to this:
function GameRunner:initiateCombatBetween(attacker, defender)
if defender:isDead() then return end
local co = CombatRound(attacker,defender)
self:addToCrawl(co:attack())
We’re not checking the attacker for being alive, And I think both of these should check for “will be dead” just in case.
function GameRunner:initiateCombatBetween(attacker, defender)
if attacker:willBeDead() or defender:willBeDead() then return end
local co = CombatRound(attacker,defender)
self:addToCrawl(co:attack())
end
Now we already had this:
function CombatRound:attack()
if self.attacker:willBeAlive() and self.defender:willBeAlive() then
self:append(self:display(" "))
local msg = string.format("%s attacks %s!", self.attacker:name(), self.defender:name())
self:append(self:display(msg))
self:attemptHit()
end
return self:getCommandList()
end
So we shouldn’t be getting to these places anyway. Is there something wrong with the willBeAlive/Dead
stuff?
I find this:
function Entity:willBeAlive()
return not self:willBeDead()
end
But:
function Entity:willBeDead()
return self._health:willBeDead()
end
So that should be OK. But we’ll change it:
function Entity:willBeAlive()
return self._health:willBeAlive()
end
I don’t think that method exists, nor is it tested. I add that:
_:test("HealthAttributes limits", function()
local ha <const> = HealthAttribute(10)
ha:accumulateDamage(6)
ha:accumulateDamage(7)
_:expect(ha:willBeDead()).is(true)
_:expect(ha:willBeAlive()).is(false)
_:expect(ha:isDead()).is(false)
_:expect(ha:isAlive()).is(true)
ha:applyAccumulatedDamage(6)
ha:applyAccumulatedDamage(7)
_:expect(ha:isDead()).is(true)
_:expect(ha:isAlive()).is(false)
_:expect(ha:value()).is(0)
_:expect(ha._accumulatedDamage).is(0)
ha:addPoints(-5)
_:expect(ha:value()).is(0)
end)
Conveniently that fails:
5: HealthAttributes limits -- Health:53: attempt to call a nil value (method 'willBeAlive')
And …
function HealthAttribute:willBeAlive()
return not self:willBeDead()
end
function HealthAttribute:willBeDead()
return self._value - self._accumulatedDamage <= 0
end
Tests run. But doubtless we still have the problem.
I feel sure that I just caused this problem, but I suppose it’s possible that it has been there all along and I just did’t notice.
If I don’t sort this soon, I’ll check out an old version and check. In fact, heck, let’s do that now. I commit first, of course. This is surely better if not perfect.
The defect seems not to occur in the old version. That’s curious.
The defect occurs in this morning’s first commit. That was pretty large, unfortunately, because HealthAttribute required a lot of fiddling. But I can check the diff between that and the one before, that did work.
I don’t see anything that could be affecting this. I see that we used to check the .alive
flag to see if an entity was alive. That’s entirely gone now.
A judicious print statement tells me that when the princess and the monster are adjacent, and it’s the princess’s turn, and she moves toward the monster, a combat round of princess against monster is created, and immediately thereafter, a round of monster on princess.
I’ve determined that in the working version, an attack is immediately initiated as in the current version, but that it is refused by this code (now including some prints):
function CombatRound:attack()
if self.attacker:willBeAlive() and self.defender:willBeAlive() then
self:append(self:display(" "))
local msg = string.format("%s attacks %s!", self.attacker:name(), self.defender:name())
self:append(self:display(msg))
self:attemptHit()
else
print("attack refused ", self.attacker:willBeAlive(), self.defender:willBeAlive())
end
return self:getCommandList()
end
The attacker is flagged as false, i.e. will not be alive. Somehow our HealthAttribute isn’t reflecting that same result. Let me review the code while I have it checked out:
Oh my
function Entity:willBeAlive()
return not self:willBeDead()
end
function Entity:willBeDead()
return self:isDead() or self.accumulatedDamage >= self.healthPoints
end
We need to check for the isDead case in our new code, I’ll wager. Because if you are dead, you will be dead.
Let me check that back out,
This is the same, net of my debug prints:
function CombatRound:attack()
if self.attacker:willBeAlive() and self.defender:willBeAlive() then
self:append(self:display(" "))
local msg = string.format("%s attacks %s!", self.attacker:name(), self.defender:name())
self:append(self:display(msg))
self:attemptHit()
end
return self:getCommandList()
end
I’ve not looked but I’m already sure the code I’m about to look at is correct:
function Entity:willBeAlive()
return self._health:willBeAlive()
end
function HealthAttribute:willBeAlive()
return not self:willBeDead()
end
function HealthAttribute:willBeDead()
return self._value - self._accumulatedDamage <= 0
end
The not
here is confusing me. I’m going to code the check directly:
function HealthAttribute:willBeAlive()
return self._value - self._accumulatedDamage > 0
end
function HealthAttribute:willBeDead()
return self._value - self._accumulatedDamage <= 0
end
I see no reason that that’s different.
Failure is still happening. This can only happen if accumulated damage isn’t working as I expect.
Look what I found:
function Entity:accumulateDamage(amount)
self.accumulatedDamage = self.accumulatedDamage + amount
end
This never got forwarded. Fix:
function Entity:accumulateDamage(amount)
self._health:accumulateDamage(amount)
end
And now I expect the problem will be gone. And wow, don’t I wish I had better tests at the Entity level?
Yes, upon testing, as expected, the defect is gone.
Let’s commit, then sum up. Commit: Fix defect where damage was not accumulated.
Summary
I think this went quite well up until I noticed that extra attack. And had I ever thought “the accumulated damage must not be right”, or checked it, I’d have found the problem right away. Basically a refactoring missed an item.
It turns out that I knew that accumulatedDamage
was being relocated into HealthAttribute
. But I forgot to remove the references. They’re still there now. I’ll remove them now and commit: remove references to member variable accumulatedDamage
in Entities.
If I had decent tests for Entities and their damage, the tests would have found this, because I’d almost certainly have rolled damage against at least one Entity and checked if it was dead, nearly dead, or soon to be dead. However, I do not have decent tests for that.
This is a very important realization. I’ve been making excuses for not testing right along here. The code is obvious; the thing is too hard to test; it works and it’s not going to change; blah blah.
Fact is, it did change and it’s a righteous change, and my tests were not there to discover this defect. The result was that I spent about an hour or more, rummaging around until I arranged things so that it was obviously accumulated damage being wrong, after which I found the problem instantly.
The tests I needed couldn’t have taken an hour or more to write. To demonstrate that that’s true, I’ll write them tomorrow, I promise. In a larger system, this problem could have been harder to find and harder to notice. It was just chance that I noticed the crawl showing at attack after the monster was dead. It would have been easy to miss that and ship a weird bug. And, at least once during testing, the dead monster killed me! That would really tick off a real user. It even made me a bit sad.
Aside from that mistake, Beth’s idea seems to be playing out nicely. A lot of logic is now nicely packed inside the HealthAttribute, and I predict that we’ll see more improvements, based on this idea, in the future.
One more thing. Let’s go back and put that not
back in here:
function HealthAttribute:willBeAlive()
return self._value - self._accumulatedDamage > 0
end
That goes back to:
function HealthAttribute:willBeAlive()
return not self:willBeDead()
end
Commit: refactor willBeAlive
.
OK, a good idea, implemented fairly well by me. See you next time!