Once more, into the breach … is it good luck when Friday the 13th falls on a Tuesday? I may need some good luck.

I really don’t enjoy writing an article like yesterday’s, when everything I tried to code came to nothing, but I tell myself that it’s important to show that it happens to most of us, and that we do best to revert, regroup, and hit it again next time.

If there was a significant mistake yesterday, it was that I didn’t revert early. I just went heads-down, not even writing in the article, bashing against the code, adding print statements, with no progress, probably for two, two-and-a-half hours.

Twenty minutes with no progress is more like a good cutoff point for me. But I felt so close … as one does. And I didn’t want to give up … as one does.

Revert, regroup, hit it again. Our mission is to modify Decor to have a hit against a different Player attribute than health. In the clarity of the aftermath, I decided that speed would be the attribute to hit, since it actually does affect combat, while strength, I think, does not.

Let’s Do It

Where do these attributes get modified?

At the bottom, it’s in CharacterAttribute:

function CharacterAttribute:accumulateDamage(damage)
    self._accumulatedDamage = self._accumulatedDamage + damage
end

We save damage in a separate field from value, because of the asynch nature of combat. When we unwind combat, we’re supposed to call this:

function CharacterAttribute:applyAccumulatedDamage(amount)
    self._value = math.max(0, self._value - amount )
    self._accumulatedDamage = math.max(0, self._accumulatedDamage - amount)
end

The sole interesting discovery, late in yesterday’s session, was that we only seem to address health in the combat round. Let’s look for senders of these messages.

function CharacterSheet:accumulateDamage(damage)
    self._health:accumulateDamage(damage)
end

Here’s a spot that is pinned to health. I have a good enough sense of what needs to be done to enhance this to allow us to access other kinds. We did this yesterday as well.

function CharacterSheet:accumulateDamage(damage, kind)
    local fetch = kind or "_health"
    local attr = self[fetch]
    attr:accumulateDamage(damage)
end

I wrote this out longhand, for clarity and out of fear that I might get it wrong. We can inline it later if it seems to matter.

More senders of accumulateDamage:

function Entity:accumulateDamage(amount)
    self.characterSheet:accumulateDamage(amount)
end

Here, we’ll just allow kind to be passed in, and pass it on.

function Entity:accumulateDamage(amount, kind)
    self.characterSheet:accumulateDamage(amount)
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

Here’s where we got into trouble yesterday. If we accept kind here, we need to pass it into the damageFrom. That could get weird.

Let’s see who’s calling applyDamage:

function CombatRound:rollDamage()
    local damage = self.random(1,6)
    self:applyDamage(damage)
end

That’s a health roll, but we do expect to see others.

function Spikes:actionWith(player)
    local co = CombatRound(self,player)
    co:appendText("Spikes ".. self.verbs[self.state]..player:name().."!")
    local damage = math.random(self:damageLo(), self:damageHi())
    co:applyDamage(damage)
    self.tile.runner:addToCrawl(co:getCommandList())
end

Spikes are happy to do health damage, though since they stab you in the feet, one could imagine doing speed damage.

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

Here’s the one we want to change. Let’s do:

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, "_speed")
    self.tile.runner:addToCrawl(co:getCommandList())
end

I’m not entirely in love with passing in the attribute’s actual name like that but we’ll worry about that later, if we ever get this to work.

Now we must change the combat round. Here’s the easy part:

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

Just the first two lines. Now we need to see about the damageFrom. That means we need to see how combat operations work.

function OP:init(op, receiver, method, arg1, arg2)
    self.op = op
    self.receiver = receiver
    self.method = method
    self.arg1 = arg1
    self.arg2 = arg2
end

We see right away that we don’t even allow for a third argument. But how are these things used?

function Provider:getItem()
    if #self.items < 1 then return self.default end
    local item = table.remove(self.items,1)
    if item.op == "display" then 
        return item.text
    elseif item.op == "extern" then
        self:execute(item)
    elseif item.op == "op" then
        self:addItems(self:execute(item))
    else
        assert(false, "unexpected item in Provider array "..(item.op or "no op"))
    end
    return self:getItem()
end

function Provider:execute(command)
    local receiver = command.receiver
    local method = command.method
    local arg1 = command.arg1
    local arg2 = command.arg2
    local t = receiver[method](receiver, arg1, arg2)
    return t
end

We can arrange for a third argument easily. But I’m wondering about that first arg being nil. Let’s look at damageFrom. Ah:

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

We don’t use the tile. Let’s remove it from the sequence, and add in kind. A test will break, but that’s easy to fix.

function Entity:damageFrom(amount, kind)
    if not self:isAlive() then return end
    self.characterSheet:applyAccumulatedDamage(amount,kind)
    if self.characterSheet:isDead() then
        sound(self.deathSound, 1, self.pitch)
        self:die()
    else
        sound(self.hurtSound, 1, self.pitch)
    end
end

This is the change we were missing yesterday. Now to fix the caller in combat round:

function CombatRound:applyDamage(damage, kind)
    self.defender:accumulateDamage(damage, kind)
    local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=damage, arg2=kind }
    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

Run, expecting a test to break.

3: rollDamage  -- Actual: nil, Expected: 4

This is the test:

        _:test("rollDamage", function()
            local result
            local i,r
            local player = FakeEntity("Princess")
            local monster = FakeEntity("Spider")
            randomNumbers = {4}
            local co = CombatRound(player, monster, fakeRandom)
            co:rollDamage()
            result = co.commandList
            i,r = next(result,i)
            _:expect(r.op).is("extern")
            _:expect(r.receiver).is(monster)
            _:expect(r.method).is("damageFrom")
            _:expect(r.arg2).is(4)
            i,r = next(result,i)
            _:expect(r.op).is("display")
            _:expect(r.text).is("Spider takes 4 damage!")
        end)

The check for arg2 should now be arg1.

OK, the tests run. But are we done. I’m not sure we are, but I’ve lost the thread. I remember there was a spurious “_health” in the system that caused a problem. I’ll search for that. It’s not there. Maybe we’re hooked up. Let’s run and find out.

Grr. It’s still affecting health. Let’s follow it all the way down.

This is OK but the reference to health is weird. Let’s do this:

function Decor:damage(aPlayer)
    local co = CombatRound(self,aPlayer)
    co:appendText("Ow! Something in there is sharp!")
    local damage = math.random(1,5)
    co:applyDamage(damage, "_speed")
    self.tile.runner:addToCrawl(co:getCommandList())
end

OK, now in combat round:

function CombatRound:applyDamage(damage, kind)
    self.defender:accumulateDamage(damage, kind)
    local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=damage, arg2=kind }
    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

Instrumenting damageFrom, I find that speed is going in there. Check applyAccumulatedDamage.

There it is:

function CharacterSheet:applyAccumulatedDamage(damage)
    self._health:applyAccumulatedDamage(damage)
end

Surprised that I didn’t find that with my search, but there it is.

function CharacterSheet:applyAccumulatedDamage(damage, kind)
    local fetch = kind or "_health"
    local attr = self[fetch]
    attr:applyAccumulatedDamage(damage)
end

I see some duplication there. And I expect this to work. And it does. The message is “Princess takes 4 damage”. I wish it could refer to the kind. Where does that come from?

function CombatRound:applyDamage(damage, kind)
    self.defender:accumulateDamage(damage, kind)
    local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=damage, arg2=kind }
    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

We can fix that readily.

    self:append(self:display(self.defender:name().." takes "..self:wordFor(kind)..damage.." damage!"))

And:

function CombatRound:wordFor(kind)
    local word = {_health="health ", _speed="speed ", _strength="strength " }
    return word or "unknown "
end

But I put the word in the wrong place. Fix:

function CombatRound:applyDamage(damage, kind)
    self.defender:accumulateDamage(damage, kind)
    local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=damage, arg2=kind }
    self:append(op)
    self:append(self:display(self.defender:name().." takes "..damage..self:wordFor(kind).." 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

And now I have to change the spacing:

function CombatRound:wordFor(kind)
    local word = {_health=" health", _speed=" speed", _strength=" strength" }
    return word or " unknown"
end

This ought to do the job. Let’s find out for sure.

Yes, well: We do have to look it up:

function CombatRound:wordFor(kind)
    local words = {_health=" health", _speed=" speed", _strength=" strength" }
    local word = words[kind]
    return word or " unknown"
end

A test fails:

3: rollDamage  -- Actual: Spider takes 4 unknown damage!, Expected: Spider takes 4 damage!

Ah. The default should be empty (or health). Empty will make the test run.

Another surprise. The speed bar decrements as it should, however, I was surprised to see “The princess is down”.

This is discouraging. I don’t feel that I can commit this until it works and again it feels a bit like I’m chasing my tail.

Anyway that message comes from here:

function CombatRound:applyDamage(damage, kind)
    self.defender:accumulateDamage(damage, kind)
    local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=damage, arg2=kind }
    self:append(op)
    self:append(self:display(self.defender:name().." takes "..damage..self:wordFor(kind).." 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

How does willBeDead work?

function CharacterSheet:willBeDead()
    return self._health:willBeDead()
end

function CharacterAttribute:willBeDead()
    return self._value - self._accumulatedDamage <= 0
end

If we’re looking at the health attribute, I don’t see who could have arranged for it to have accumulated damage.

Someone with a default call, I guess.

It’s time to revert, regroups, come back at it later. I can feel it in my gut.

Again, the big fool says to press on.

Instrumenting:

function CharacterSheet:accumulateDamage(damage, kind)
    local fetch = kind or "_health"
    local attr = self[fetch]
    print("CS accum ", fetch)
    attr:accumulateDamage(damage)
end

function CharacterSheet:applyAccumulatedDamage(damage, kind)
    local fetch = kind or "_health"
    local attr = self[fetch]
    print("CS apply ", fetch)
    attr:applyAccumulatedDamage(damage)
end

I find that accum is getting health. Something isn’t wired up quite right. Where is that done?

Somehow I lost a method. Gotta put it back. Fortunately it’s in the article.

function CharacterSheet:applyAccumulatedDamage(damage, kind)
    local fetch = kind or "_health"
    local attr = self[fetch]
    attr:applyAccumulatedDamage(damage)
end

My prints say that CharacterSheet:accumulateDamage is storing into health. It’s being called with a nil. Ah:

function Entity:accumulateDamage(amount, kind)
    self.characterSheet:accumulateDamage(amount,kind)
end

That wasn’t passing in kind. What we really should do it make kind required, and check for it. But not right now, maybe …

works

Ah. It works. Remove prints, commit: dangerous decor deals speed damage.

Success … Sort of …

The rudimentary capability is in. We’ll want to make Decor deal various types of damage and so on, but the wires are all hooked up. So we have a success.

Sort of.

Its just turning 9 AM, which means I’ve been working for about 90 minutes and this is my first commit. The code is solid, I think, but could use some improvement, which we’ll look at next time.

But again, I got into trouble, due to the intricate way that damage is passed into the future and the past, all due to the CombatRound’s complex relationship with reality.

CombatRound executes a battle instantly, and records the results in the crawl, via Provider. We don’t want the entity to turn its dead color, or teleport to room 1, until the combat is over, but the combat decisions need to be made based on the values of the entity when the combat is unwound.

For example, if a previous combat cycle will have killed the princess, another entity that might attack doesn’t get an attack: it would look weird and be cruel. So the combat round asks whether the defender “will be” dead, and the need to do that means that we have two accumulators in the attributes, the visible value, and a hidden value that may be less, because it includes damage that combat round has assigned but that has not yet been reported?

Are you confused yet? If so, rightly so. It is weird, complicated, and really tricky to work with.

So the long duration before this tiny feature worked together with the rather small number of lines actually changed, around 25, tells us that we have a design issue here.

This is no surprise. Sometimes I think that the coroutine approach was better. So far I haven’t thought of another approach that I like, but yesterday and today make me aware that changes in this area are difficult and risky.

I could benefit from more tests, too, since most (all?) of what I did today had no useful test coverage. It should be possible to write some tests for this, and maybe I should have.

Summing up, better than yesterday, and this time we have something that works. Let’s ship it!

See you next time!


D2.zip