Let’s work a bit further on combat. I’ll start with something easy, but there are some possibly tricky roads ahead. (Plus, Ron comes out against death.)

The point of the new CombatOperation is to permit a more interesting and flexible combat model for our game. The meta-point, of course, is to try to throw our incremental design off the rails, by asking ourselves for things we clearly didn’t prepare for. That provides information about my theory that a well-structured program is ready for just about anything.

Things coming up that may be difficult include player input, and smarter monsters. Player input will require human-scale delays while the player decides what to do. And we have done some preparation for that, in that our new Provider object supplies default lines to the Floater crawl when it has nothing to say. At the time we built that, it was user input delays that we had in mind. And that change went in quite nicely, and turns out to be less complicated than my original genius idea of coroutines.

Smarter monsters, well, I don’t know yet. Maybe there are some that just follow you around but don’t cause trouble. Maybe there are some who leap out and surround you. Maybe some do little dance routines. Who knows, not me.

I think the next “big” thing needs to be player input. I don’t want to spend mass quantities of time building a pretty GUI. We all know how to do that, and the lessons will be few. We’ll want an interface that puts the structure in place, and that shows we could make it lovely if we wanted to spend the time. After that, we’ll probably move on to something more interesting.

I’m not sure if we’ll get to player input today. It could be tomorrow. It could be a week from Wednesday, if some amazing new idea pops up. But most likely today or tomorrow.

First, though, I want to complete a combat round that works rather like our previous version of combat, namely someone attacks, does some damage. Maybe someone goes down. You know, that horrid deadly fun we seem to put in our games.

Hm. These are not good days to think of death, for me, and probably for many of us. Maybe we’ll change the game pattern. Maybe when someone “wins”, the defender is knocked out, rather than killed. That would comfort me.

Let’s get started:

Damage

Our CombatOperation currently has these operations:

  • attack - initiate an attack from an attacker against a defender;
  • attemptHit - attacker tries to hit defender
  • display - place a message in the Crawl
  • counterAttack - give a turn to the defender.

Currently attemptHit just displays “X whacks Y”, which isn’t quite right, unless this is a Punch and Judy show. We want to move toward two aspects of combat that are similar to D&D. In D&D, first one rolls to determine whether one has hit, then, if one has hit, one rolls for damage. The chances of a hit depend on speed and armor. The damage depends on the weapon used. D&D also has “critical hits” and pure misses, each occurring 1/20th of the time.

We’ll work toward these feature incrementally, as is our fashion.

So. Let’s imagine a new CombatOp, rollDamage, that will be put into the stream if the attempted hit works. Our attemptHit op will be extended to inject a rollDamage as appropriate.

Let’s start with a certainty of rolling damage.

Ron sighs … and yes, OK, let’s TDD this.

Here’s our current test for attemptHit:

        _:test("attempt hit", function()
            local result
            local i,r
            local player = FakeEntity("Princess")
            local monster = FakeEntity("Spider")
            local co = CombatOperation(player, monster)
            result = co:attemptHit()
            i,r = next(result,i)
            _:expect(r.op).is("display")
            _:expect(r.text).is("Princess whacks Spider!")
        end)

We’re going to have to work on this in a few cycles, I think, because we’re going to need to deal with random numbers Real Soon Now, but I think we can defer that until we do the rollDamage any second now. So first, emit it:

...
            _:expect(r.text).is("Princess whacks Spider!")
            i,r = next(result,i)
            _:expect(r.op).is("op")
            _:expect(r.receiver).is(co)
            _:expect(r.method).is("rollDamage")

That should conveniently fail:

2: attempt hit  -- Actual: display, Expected: op

I wasn’t expecting that. I think the issue is that the attemptedHit is triggering the defender’s turn. I don’t think I even tested that, did I? Just put it in?

I think getting the defender turn to come out at the right time may turn out to be tricky, but for now, I’ll just remove it. It was here, where we’ll be editing anyway:

function CombatOperation:attemptHit()
    local result = {}
    local msg = string.format("%s whacks %s!", self.attacker:name(), self.defender:name())
    table.insert(result, self:display(msg) )
    --self:counterAttack(result)
    return result
end

Now I should get the failure I expect:

2: attempt hit -- TestCombatOperation:44: attempt to index a nil value (local 'r')

Now we just want to put in the new operation.

function CombatOperation:attemptHit()
    local result = {}
    local msg = string.format("%s whacks %s!", self.attacker:name(), self.defender:name())
    table.insert(result, self:display(msg) )
    local op = { op="op", receiver=self, method="rollDamage" }
    table.insert(result, op)
    return result
end

I’m thinking that this tabular setup for ops is getting repetitive and needs improvement. Soon. Now I think my test is supposed to run.

2: attempt hit  -- OK

Now we need to test our new rollDamage op. It needs to do two things: issue damage against defender, and display the damage message.

        _:test("rollDamage", function()
            local result
            local i,r
            local player = FakeEntity("Princess")
            local monster = FakeEntity("Spider")
            local co = CombatOperation(player, monster)
            result = co:rollDamage()
            i,r = next(result,i)
            _:expect(r.op).is("op")
            _:expect(r.receiver).is(monster)
            _:expect(r.method).is("damageFrom")
            _:expect(r.arg2).is(4)
        end)

Now honestly, I’m not really sure if the syntax is arg2 or not, but it should be something like that. We had arguments before, but our new CombatOperation doesn’t have them yet. And we’re really starting to feel the need for something more robust than a table here.

Let’s make it work. Test should fail horribly:

3: rollDamage -- TestCombatOperation:55: attempt to call a nil value (method 'rollDamage')

So far so good. I went ahead and implemented more than the test calls for:

function CombatOperation:rollDamage()
    local result = {}
    local op = { op="op", receiver=self.defender, method="damageFrom", arg1=nil, arg2=4 }
    table.insert(result,op)
    table.insert(result, co:display(self.defender:name().." takes 4 damage!"))
    return result
end

I do think the test should pass. I am mistaken:

3: rollDamage -- TestCombatOperation:138: attempt to index a nil value (global 'co')

I should have said “self”.

function CombatOperation:rollDamage()
    local result = {}
    local op = { op="op", receiver=self.defender, method="damageFrom", arg1=nil, arg2=4 }
    table.insert(result,op)
    table.insert(result, self:display(self.defender:name().." takes 4 damage!"))
    return result
end

Are you wondering why lines in CombatOperation are coming out of the tab TestCombatOperation? It’s because I’m building the class and the tests together. I find that more useful while things are small. I’ll probably break them apart in due time.

Now I expect the test to run. And it does. I think this could be interesting in the game.

It is, but not as I anticipated:

Provider:13: did not get a table in addItems
stack traceback:
	[C]: in function 'assert'
	Provider:13: in method 'addItems'
	Provider:34: in function <Provider:28>
	(...tail calls...)
	Floater:40: in method 'fetchMessage'
	Floater:51: in method 'increment'
	Floater:35: in method 'draw'
	GameRunner:197: in method 'drawMessages'
	GameRunner:159: in method 'draw'
	Main:30: in function 'draw'

This came out after the “Princess whacks” message. Also I realize that I didn’t put the check for the message in the test:

...
            i,r = next(result,i)
            _:expect(r.op).is("display")
            _:expect(r.text).is("Spider takes 4 damage!")

Test still runs. I wonder what’s wrong with the actual game. Thinking isn’t helping me, I’d best read the code:

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 == "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

We must have come from that line with the arrow on it. So we executed something … and didn’t get a collection back.

I instrument rollDamage to be sure we get there and return:

function CombatOperation:rollDamage()
    print("rollDamage")
    local result = {}
    local op = { op="op", receiver=self.defender, method="damageFrom", arg1=nil, arg2=4 }
    table.insert(result,op)
    table.insert(result, self:display(self.defender:name().." takes 4 damage!"))
    print("...ends ", #result)
    return result
end

That prints:

rollDamage
...ends 	2
Provider:13: did not get a table in addItems
stack traceback:
	[C]: in function 'assert'
	Provider:13: in method 'addItems'
	Provider:34: in function <Provider:28>
	(...tail calls...)
	Floater:40: in method 'fetchMessage'
	Floater:51: in method 'increment'
	Floater:35: in method 'draw'
	GameRunner:197: in method 'drawMessages'
	GameRunner:159: in method 'draw'
	Main:30: in function 'draw'

So, as seems clear from the code, rollDamage did return a collection. I bet, however, that damageFrom does not. We shouldn’t expect it to. Ah.

We have an important defect here

When our combat op is calling back to the CombatOperation object, it is expected to return a table of new ops, possibly empty. But when we call methods on other objects, we should have no such expectation. I think it’s an open question whether they should even be allowed to return combat ops. It might make sense if they could, but I’m far from sure.

Digression on Little Languages

When we build little languages like this, we continually come up against issues regarding how flexible the language should be and how powerful it should be. Right now, we just see our ops as producing more ops until they stop, with side effects of calling other methods as we need to.

That’s what’s happening here.

End Digression

I think the implicit assumption is that the other methods do not know about CombatOperation at all. That makes sense, since otherwise there’s coupling that we’d like to avoid. So we can’t require any method that might be called from a CombatOp to return an empty collection. That would be burdensome.

Instead let’s do this. We’ll have two kinds of code-executing ops, ones that call back into CombatOperation, and ones that are calling externally. We’ll give those new ones a new opcode. There’s just the one so far.

I’ll change the test:

        _:test("rollDamage", function()
            local result
            local i,r
            local player = FakeEntity("Princess")
            local monster = FakeEntity("Spider")
            local co = CombatOperation(player, monster)
            result = co:rollDamage()
            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)

New opcode “extern”. The code:

function CombatOperation:rollDamage()
    print("rollDamage")
    local result = {}
    local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=nil, arg2=4 }
    table.insert(result,op)
    table.insert(result, self:display(self.defender:name().." takes 4 damage!"))
    print("...ends ", #result)
    return result
end

Test runs. Now Provider:

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

Now I expect to see damage on whatever I attack.

attack

Well, yes, but when the AnkleBiter dies, something bad happens.

Provider:38: unexpected item in Provider array no op
stack traceback:
	[C]: in function 'assert'
	Provider:38: in method 'getItem'
	Floater:40: in method 'fetchMessage'
	Floater:51: in method 'increment'
	Floater:35: in method 'draw'
	GameRunner:197: in method 'drawMessages'
	GameRunner:159: in method 'draw'
	Main:30: in function 'draw'
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

So whatever was in there wasn’t an opcode. Let’s enhance that error message and see what’s up. I’m honestly not sure.

Ah. When I print the item, I find:

Yellow Widow is dead!

And of course:

function Entity:damageFrom(aTile,amount)
    if not self:isAlive() then return end
    self.healthPoints = self.healthPoints - amount
    if self.healthPoints <= 0 then
        self.runner:addToCrawl({self:name().." is dead!"})
        sound(self.deathSound, 1, self.pitch)
        self:die()
    else
        sound(self.hurtSound, 1, self.pitch)
    end
end

Yes, well, you can’t just be adding text to the crawl any more can you? Let’s create a new method on GameRunner and use it here:

        self.runner:addTextToCrawl({self:name().." is dead!"})

(I removed the {…} brackets. Just pass a string.)

function GameRunner:addTextToCrawl(aString)
    self:addToCrawl(CombatOperation():display(aString))
end

Now let’s see what we can see. But first, the cat needs breakfast. OK, cat starvation narrowly averted. Let’s try another battle.

no death

Well that surprised me. The message didn’t come out. Oh. I didn’t put it into a table. Should be:

function GameRunner:addTextToCrawl(aString)
    self:addToCrawl({CombatOperation():display(aString)})
end

correct

I was thinking of refactoring to have an addItem method instead of just addItems, but that method is on Floater, which forwards to Provider, so I’d have to push the method clear down. And shouldn’t we bring the Provider to the fore, not the Floater, anyway?

Let’s first commit: Princess attacks with 4 damage.

As things stand now, the Floater is the primary object, with a built-in Provider. The GameRunner has the official floater, and people can ask the GameRunner to add items to it. I decide to let that structure ride, but I still want to add just one item, as it seems less error-prone than requiring me to remember to wrap tables around things.

function Floater:addItem(item)
    self.provider:addItem(item)
end

function Floater:addItems(array)
    self.provider:addItems(array)
end

function Provider:addItem(item)
    table.insert(self.items, item)
end

function Provider:addItems(array)
    assert(type(array)=="table", "did not get a table in addItems")
    for i,item in ipairs(array) do
        self:addItem(item)
    end
end

function GameRunner:addTextToCrawl(aString)
    self.cofloater:addItem(CombatOperation():display(aString))
end

Test, then commit: addItem helper method added to GameRunner, Floater, Provider.

Now let’s roll us some dice. This is where it gets tricky to test. Let’s look first at what the code wants to do, which is to apply a random damage based on weapon. Right now, we don’t have weapons. Let’s imagine that our default weapon, whatever it is, rolls 1d6 damage. So what we’d like to write is something like this:

function CombatOperation:rollDamage()
    local result = {}
    local damage = math.random(1,6)
    local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=nil, arg2=damage }
    table.insert(result,op)
    table.insert(result, self:display(self.defender:name().." takes "..damage.." damage!"))
    return result
end

Now what would we like to test about this code? On the one hand, it pretty obviously works. On the other hand, our test will now fail 5 out of 6 times:

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

We need to find a way to control the output of the random number generator, or to accept a weaker test. Right now, the code can’t fail, but by the time we get to critical hits and such, with multiple rolls, it might fail. We do have a fake random number generator that we can use. It’s just a function that looks like this:

    function fakeRandom(aNumber)
    randomIndex = randomIndex + 1
    local rand = randomNumbers[randomIndex]
    if rand == nil then
        assert(false, "off end of random numbers "..randomIndex)
    end
    return rand
end

It uses global-ish variables randomNumbers and randomIndex in its provision. It is intended to be copied into a test tab and used there. We could imagine a more robust test double, but we try not to need this kind of double often.

Let’s copy it into our CombatOp test.

local randomNumbers
local randomIndex

        _:before(function()
            randomNumbers = {}
            randomIndex = 0
        end)

Then we can inject it into our CombatOps under test:

function CombatOperation:init(attacker,defender, rng)
    self.attacker = attacker
    self.defender = defender
    self.repeats  = true
    self.random = rng or math.random
end

function CombatOperation:rollDamage()
    local result = {}
    local damage = self.random(1,6)
    local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=nil, arg2=damage }
    table.insert(result,op)
    table.insert(result, self:display(self.defender:name().." takes "..damage.." damage!"))
    return result
end

And finally in our test:

        _:test("rollDamage", function()
            local result
            local i,r
            local player = FakeEntity("Princess")
            local monster = FakeEntity("Spider")
            randomNumbers = {4}
            local co = CombatOperation(player, monster, fakeRandom)
            result = co:rollDamage()
            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)

I am hopeful about this, but there could be a wiring error.

3: rollDamage  -- OK (6 times)

So we’re good. Let’s commit: added use of fakeRandom to tests of CombatOp.

Now we should find a bit more reasonable damage happening.

two

Well, she happened to roll two 2s but that’ll be fine.

Now let’s put the defender’s turn back in. There’s still no way for a monster to create an attack, but we’re close to that.

We should be able just to call counterAttack at the right point.

function CombatOperation:rollDamage()
    local result = {}
    local damage = self.random(1,6)
    local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=nil, arg2=damage }
    table.insert(result,op)
    table.insert(result, self:display(self.defender:name().." takes "..damage.." damage!"))
    self:counterAttack(result)
    return result
end

This should give us a counter attack, and it should happen even if the monster is dead. (They’re tough, monsters.)

dead

As I predicted, the dead MurderHornet attacks and does damage. Tricky, these hornets. I think we may be able to fix this bug. Let’s even try to write a test for it. We’re going to start all combat with a call to attack, so if that were to check for the combatant being dead and just return, that would be sufficient.

I know I could just code this. These tests are a pain to set up, but I think they’re going to pay off as we go forward making the CombatOp setup more and more complex.

        _:test("can't attack dead things", function()
            local player = FakeEntity("Princess")
            local monster = FakeEntity("Spider")
            monster:die()
            local co = CombatOperation(player, monster)
            local result = co:attack()
            _:expect(#result).is(0)
        end)

This fails first on die, I reckon:

5: can't attack dead things -- TestCombatOperation:83: attempt to call a nil value (method 'die')

So we need this:

function FakeEntity:init(name)
    self.cognomen = name
    self.alive = true
end

function FakeEntity:die()
    self.alive = false
end

As I often do, I implement a bit more than the test demands. I’m here to get reasonable confidence, not to play out some ritual, so I test and implement at what I deem to be the right size. When I’m wrong, I adjust.

5: can't attack dead things  -- Actual: 2, Expected: 0

Now in attack:

function CombatOperation:attemptHit()
    if self.defender:isDead() then return {} end
    local result = {}
    local msg = string.format("%s whacks %s!", self.attacker:name(), self.defender:name())
    table.insert(result, self:display(msg) )
    local op = { op="op", receiver=self, method="rollDamage" }
    table.insert(result, op)
    return result
end

This should evoke a failure on isDead. But curiously it does not fail. I get this again:

5: can't attack dead things  -- Actual: 2, Expected: 0

Hm I do get the expected error in another test:

2: attempt hit -- TestCombatOperation:128: attempt to call a nil value (method 'isDead')

I wonder why I don’t get it in both? I do not know. I’ll implement the method for now.

function FakeEntity:isAlive()
    return self.alive
end

function FakeEntity:isDead()
    return not self:isAlive()
end

Test again. The error in attempt hit goes away but I still get:

5: can't attack dead things  -- Actual: 2, Expected: 0

What have I done wrong?

Well, for starters the test name is wrong and therefore the implementation is wrong too:

        _:test("dead things can't attack", function()
            local player = FakeEntity("Princess")
            local monster = FakeEntity("Spider")
            player:die()
            local co = CombatOperation(player, monster)
            local result = co:attack()
            _:expect(#result).is(0)
        end)

Now then:

function CombatOperation:attemptHit()
    if self.attacker:isDead() then return {} end
    local result = {}
    local msg = string.format("%s whacks %s!", self.attacker:name(), self.defender:name())
    table.insert(result, self:display(msg) )
    local op = { op="op", receiver=self, method="rollDamage" }
    table.insert(result, op)
    return result
end

This better work. But no:

5: dead things can't attack  -- Actual: 2, Expected: 0

I have to be missing something really silly, but what?

I literally laugh. I’m modifying attemptHit, not attack.

function CombatOperation:attack()
    if self.attacker:isDead() then return {} end
    local result = {}
    local msg = string.format("%s attacks %s!", self.attacker:name(), self.defender:name())
    table.insert(result, self:display(msg) )
    local cmd = { op="op", receiver=self, method="attemptHit" }
    table.insert(result,cmd)
    return result
end

Test runs. But misnaming the test reminded me, what should we do if the defender is dead? Can this happen? This is an initial attack, so I think it cannot.

function Player:startActionWithMonster(aMonster)
    if aMonster:isDead() then return end
    local co = CombatOperation(self,aMonster)
    self.runner:addToCrawl(co:attack())
end

Right. We won’t try. Later on, if and when we deal with multiple monsters in a single CombatOp, we may want to do something more clever. For now, let’s let the Monsters initiate combat if they care to. I think we emptied out their equivalent method to the above. Filling it in, we get this:

function Monster:startActionWithPlayer(aPlayer)
    if aPlayer:isDead() then return end
    local co = CombatOperation(self,aPlayer)
    self.runner:addToCrawl(co:attack())
end

Now at this point, if I touch one of the fight or flight buttons, which amounts to a no-op, a monster might attack the Princess.

death fly

That doesn’t look right, does it? Let me review that crawl in detail.

  1. Death Fly attacks Princess!
  2. Death Fly attacks Princess!
  3. Death Fly attacks Princess!
  4. Death Fly whacks Princess!
  5. Death Fly whacks Princess!
  6. Death Fly whacks Princess!
  7. Princess takes 4 damage!
  8. Princess attacks DeathFly.
  9. etc

What has happened is that the Death Fly rolled three moves, all attacking the Princess, thus enqueueing 3 CombatOps, which then unwind in some kind of bizarre braid. We really need to prevent more than one CombatOp at a time.

For now, let’s change monster movement to be only one cell at a time. That should give a legit test for a single attack.

function GameRunner:moveMonsters()
    for k,m in pairs(self.monsters) do
        m:chooseMove()
        if math.random() > 0.67 then m:chooseMove() end
        if math.random() > 0.67 then m:chooseMove() end
    end
end

We’ll comment out the extra moves for now:

function GameRunner:moveMonsters()
    for k,m in pairs(self.monsters) do
        m:chooseMove()
        --if math.random() > 0.67 then m:chooseMove() end
        --if math.random() > 0.67 then m:chooseMove() end
    end
end

Now let’s give a monster another shot at us:

ankle biter

That does the trick. In the fullness of time we’ll figure out some kind of interlock. For now, commit: Monsters can attack on their turn. Monsters only move one cell per move.

I think we’ve done well enough for the day and it is 1150, so I can think about reading or having a bite. Let’s sum up.

Sum(up)

We added the ability to roll damage, and tested the op that does it, including injecting a fake random number generator to allow sensible testing. We’re now using three fakes in our combat tests, two FakeEntity and one fakeRandom. I don’t really like using test doubles like those, but as you can probably see here, they can save a lot of difficult setup.

Arguably I should be more open to them.

We now have some interesting conditional behavior in CombatOp, including not attacking when you’re dead, which lets us keep the counterAttack logic pretty simple.

We haven’t, as yet, put all the needed conditional behavior into the attack method, which can in principle attempt an attack and miss, or gain double damage. All things in their time, and it’s not time for that, yet.

So far, the new CombatOp idea is bearing weight nicely. We’ll continue to move forward.

See you next time!


D2.zip