More on Combat. I have now been advised by my betters on how, possibly, to test this thing. I may have an angle.

I’ve been struggling with whether to TDD or otherwise test the CombatRound. My moral sense says that it is full of branching logic and I could easily enough write

    roll(a) < roll(d)

when

    roll(d) < roll(a)

is correct.

So testing of some kind is needed. And as combat evolves, the Round will surely get more and more complicated. Again, this calls for tests, and persistent ones, not just manually checking and relying on correctly checking again a week from now.

I had no good idea for how to set this up for testing that wasn’t incredibly tedious to do. However, a conversation with my betters on a Slack I hang on, including Ryan, Tim, GeePaw, Bill, and Bryan, has given me a sort of idea that seems testable, plus a strong reason to do it.

It may be already clear from preceding articles that combat will consist of a series of checks, and appending text and commands to the Floater, repeat until done:

  • Dexterity Check:
  • “Princess attacks Spider”
  • Hit Check:
    • Clear Miss!, or
    • Damage Check:
    • “Spider takes x Damage”, or
    • “Critical Hit! Double Damage!”;
    • Damage Check:
    • Spider takes 2x Damage”

And so on.

An idea came out of the discussion, primarily from GeePaw. As I understand it–he said a bunch of words, and now there is this idea in my head, which may or may not be the idea in his head–a combat amounts to a growing list of operations. You start with one, and when it runs, it emits more operations onto the end of the list, and when the list is empty, you’re done.

Whether the operations include putting text into the crawl, and deferred operations into the crawl, I’m not clear, and certainly my betters don’t know enough about this program to have a solid view. But it could be, couldn’t it? We already have this thing that executes lines from an array, the Floater/crawl. (I think I should rename it Crawl and get it over with.) The lines are provided by the aptly-named Provider, which presently understands two kinds of array items, text, which is provided to the Floater to b displayed, and tables, which are commands to be executed.

Right now, I envision commands as operations that need to be scheduled when some future line of text comes out, so that screeching EEEK is synchronized with the line “Spider bites Princess”. But there’s nothing saying that a command couldn’t append more to the Provider’s array.

So the idea is something like this:

The Provider gets a list of “Operations”, which can be “DisplayThisText”, or “Do This Thing”, and among the things that can be done is to append more Operations to the list. Each operation is pretty atomic, and could therefore be individually tested with reasonable TDD.

And I have come up with a reason why this thing should go under TDD, despite my protestation that I can just type it in correctly the first time, no problem:

Although our initial Combat will be pretty simple, we may well want to build a sort of “Monster AI” that gives the monsters more and more complex behavior. Even as I write the sample scripts in these article, I’m thinking that the Serpent should poison, and so on. So there may be more and more operations to deal with.

Thus, today’s plan:

Build Combat Operations for Provider

Let’s see what’s in there now. It has been literally hours since I knew.

Oh yes, this:

-- CombatRound
-- RJ 202102028

CombatRound = class()

function CombatRound:init(player,monster)
    self.player = player
    self.runner = player.runner -- sorry
    self.monsters = { monster }
    self.results = {}
    self:run()
    self:report()
end

function CombatRound:addResult(item)
    table.insert(self.results,item)
end

function CombatRound:monsterName(i)
    return self.monsters[i]:name()
end

function CombatRound:playerName()
    return self.player:name()
end

function CombatRound:report()
    self.player.runner:runCrawl(self.results)
end

function CombatRound:run()
    self:addResult(self.player:name().." attacks "..self.monsters[1]:name().."!")
    local damage = math.random(1,self.player:strength())
    self:addResult({ self.monsters[1], "damageFrom", nil, damage} )
    self:addResult(self:monsterName(1).." takes "..damage.." damage!")
end

That’s not very testable, even now. Let’s rip it out and try something new, along the lines of the discussion above. For some reason, I have 5 files uncommitted. I’ll just commit and move from wherever I am now.

First, removing CombatRound and references. I’m left with the need to do something when the player attacks a monster:

function Player:startActionWithMonster(aMonster)
    if aMonster:isDead() then return end
end

Now the “theory” is that we’ll just add an “operation” to the Provider, and that operation will do what’s needed. For example:

function Player:startActionWithMonster(aMonster)
    if aMonster:isDead() then return end
    self.runner:addToCrawl({self:name().." blows raspberry at "..aMonster:name()})
end

That works:

raspberry

I initially left the curly brackets off the call to addToCrawl, which resulted in no output, because the function wants to receive an array. I suspect this will become a common error, and that we will need to do something about it. But not yet: I’m not sure I know how the interface really wants to go.

Let’s think of the Provider having an array of “operations”. Right now, it has text or table:

function Provider:getItem()
    if #self.items < 1 then return self.default end
    local item = table.remove(self.items,1)
    if type(item) == "string" then 
        return item
    else
        self:execute(item)
    end
    return self:getItem()
end

function Provider:execute(command)
    local receiver = command[1]
    local method = command[2]
    local arg1 = command[3]
    local arg2 = command[4]
    receiver[method](receiver, arg1, arg2)
end

If the item isn’t a string, it is expected to be a command, in the form { receiver, method, arg1, arg2 }.

Before I start creating and testing operations, let’s see what they might be like. The first things that happen after the Princess moves on a Spider should be something like this:

  • Say “Princess attacks Spider”
  • Dexterity Check to see if she hits
  • if she misses
    • Say “Princess misses!”
  • If she hits
    • Roll Damage d
    • Apply damage d to spider
    • Say “Spider takes d damage!”
  • If she rolls 20
    • Say “Critical Hit, Double Damage!”
    • Roll damage d1
    • Roll damage d2
    • Apply damage d1+d2 to Spider
    • Say “Spider takes d1+d2 damage!”

Bill said he was channeling his “Inner Ron” and asked what I’d tell a rank beginner to do. I’m not sure, but a conversation might lead to the idea of an object that performs “combat operations”, where we could test those operations and check what they were going to stuff into the Provider. So let’s see if we can write a

Combat Operation Test

I sketch this test:

        _:test("First Attack", function()
            local co = CombatOperation()
            local player
            local monster
            co:attack(player,monster)
        end)

Let’s try this convention: each CombatOperation will return an array of things to be added to the provider, consisting of strings, new CombatOperations, and other messages to be sent. We’ll start with the assumption that the string vs table distinction is enough to let us sort things out, but we observe that there should probably be a more explicit kind of operation code thing in here somewhere. For now …

        _:test("First Attack", function()
            local result
            local i,v
            local co = CombatOperation()
            local player
            local monster
            result = co:attack(player,monster)
            i,r = next(result,i)
            _:expect(r).is("Princess attacks Spider!")
        end)

(We’ll use the next trick we learned yesterday, to tick through the array.)

The first thing we want is that message.

We have a FakePlayer object, used in Loot. We don’t have a FakeMonster. We would prefer not to have to gin up real monsters and players for this exercise. I think rather than have to reuse the Loot’s one, I’ll make a new FakePlayer for here. And In order to make that work, I’ll declare the one in Loot local, and this one local as well.

I decided that I don’t need separate monster and player fakes yet, so I just create one class:

local FakeEntity = class()

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

function FakeEntity:name()
    return self.cognomen
end

Now to use them:

        _:test("First Attack", function()
            local result
            local i,v
            local co = CombatOperation()
            local player = FakeEntity("Princess")
            local monster = FakeEntity("Spider")
            result = co:attack(player,monster)
            i,r = next(result,i)
            _:expect(r).is("Princess attacks Spider!")
        end)

This test fails demanding CombatOperation.

1: First Attack -- TestCombatOperation:20: attempt to call a nil value (global 'CombatOperation')

We respond:

CombatOperation = class()

function CombatOperation:init(player,monster)
    self.player = player
    self.monster = monster
end

Writing that reminds me that the CO needs the entities:

        _:test("First Attack", function()
            local result
            local i,v
            local player = FakeEntity("Princess")
            local monster = FakeEntity("Spider")
            local co = CombatOperation(player, monster)
            result = co:attack(player,monster)
            i,r = next(result,i)
            _:expect(r).is("Princess attacks Spider!")
        end)

This should fail looking for attack, but it doesn’t:

1: First Attack -- TestCombatOperation:20: attempt to call a nil value (global 'FakeEntity')

This is that darn CodeaUnit thing about not knowing stuff that isn’t defined yet. I guess I can’t make those things local after all. Bummer. OK, now I get:

1: First Attack -- TestCombatOperation:23: attempt to call a nil value (method 'attack')

We build:

function CombatOperation:attack()
    result = {}
    msg = string.format("%s attacks %s!", self.player:name(), self.monster:name())
    table.insert(result,msg)
    return result
end

We expect this to pass. And it does:

1: First Attack  -- OK

We note that we don’t need to pass in player and monster again, so we revise the test:

        _:test("First Attack", function()
            local result
            local i,v
            local player = FakeEntity("Princess")
            local monster = FakeEntity("Spider")
            local co = CombatOperation(player, monster)
            result = co:attack()
            i,r = next(result,i)
            _:expect(r).is("Princess attacks Spider!")
        end)

Now, given that the princess has attacked the spider, the operation needs to put the next operation into the array. That is to attempt a hit, so let’s try this:

        _:test("First Attack", function()
            local result
            local i,v
            local player = FakeEntity("Princess")
            local monster = FakeEntity("Spider")
            local co = CombatOperation(player, monster)
            result = co:attack()
            i,r = next(result,i)
            _:expect(r).is("Princess attacks Spider!")
            i,r = next(result,i)
            _:expect(r[1]).is(co)
            _:expect(r[2]).is("attemptHit")
        end)

Now we have but to implement that. The error message tells me that r is a global. I had “v” in the test where I meant “r”:

        _:test("First Attack", function()
            local result
            local i,r
            local player = FakeEntity("Princess")
            local monster = FakeEntity("Spider")
            local co = CombatOperation(player, monster)
            result = co:attack()
            i,r = next(result,i)
            _:expect(r).is("Princess attacks Spider!")
            i,r = next(result,i)
            _:expect(r[1]).is(co)
            _:expect(r[2]).is("attemptHit")
        end)

The test is guiding me to improve the test and the code.

At this point, r is nil, since there’s nothing in the array after the string. We fix that:

function CombatOperation:attack()
    local result = {}
    local msg = string.format("%s attacks %s!", self.player:name(), self.monster:name())
    table.insert(result,msg)
    local cmd = { self, "attemptHit" }
    table.insert(result,cmd)
    return result
end

I expect this to work. And it does.

Now as I write this test, I realize that indexing into a raw table isn’t exactly super clear. And dispatching on “is a string” vs “isn’t a string” isn’t the best either. Let’s start building a little “language” here. We’ll start by just giving the returned table some expected structure, but we may well go beyond that:

            _:expect(r).is("Princess attacks Spider!")
            i,r = next(result,i)
            _:expect(r.receiver).is(co)
            _:expect(r.method).is("attemptHit")

That’s easy to do:

function CombatOperation:attack()
    local result = {}
    local msg = string.format("%s attacks %s!", self.player:name(), self.monster:name())
    table.insert(result,msg)
    local cmd = { receiver=self, method="attemptHit" }
    table.insert(result,cmd)
    return result
end

Test still runs. Let’s go a step further and give the other return some meaning as well. Let’s introduce a new name tag, “op” for operation. Like this:

        _:test("First Attack", function()
            local result
            local i,r
            local player = FakeEntity("Princess")
            local monster = FakeEntity("Spider")
            local co = CombatOperation(player, monster)
            result = co:attack()
            i,r = next(result,i)
            _:expect(r.op).is("display")
            _:expect(r.text).is("Princess attacks Spider!")
            i,r = next(result,i)
            _:expect(r.op).is("op")
            _:expect(r.receiver).is(co)
            _:expect(r.method).is("attemptHit")
        end)

Now we have a simple (and slightly odd) language, with items:

  • op=”display”, text=aString
  • op=”op”, receiver=anObject, method=aString

We may find that we want to adjust these but they’ll do for now. And now we have arranged things so that our Crawl doesn’t need to make so many assumptions.

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

Let’s test a rudimentary attemptHit and then see if we can plug this in to the actual game.

        _: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)

When this tests runs, we should be able to have a brief encounter with a monster.

function CombatOperation:attemptHit()
    local result = {}
    local msg = string.format("%s whacks %s!", self.player:name(), self.monster:name())
    table.insert(result, { op="display", text = msg } )
    return result
end
2: attempt hit  -- OK
2: attempt hit  -- OK

So that’s good. Can we plug this in to our existing code? Let’s go back to our player and see:

function Player:startActionWithMonster(aMonster)
    if aMonster:isDead() then return end
    self.runner:addToCrawl({self:name().." blows raspberry at "..aMonster:name()})
end

We can certainly add the first table to the crawl thusly:

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

This should change the message on the attack, but we need to improve the Provider, which looks like this:

function Provider:getItem()
    if #self.items < 1 then return self.default end
    local item = table.remove(self.items,1)
    if type(item) == "string" then 
        return item
    else
        self:execute(item)
    end
    return self:getItem()
end

We now expect those things with “op” and such. So:

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:execute(item)
    else
        assert(false, "unexpected item in Provider array "..item.op)
    end
    return self:getItem()
end

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

We’re not using any args yet, so they’ll be nil, which should be OK.

This can’t possibly work but should be close. Let’s see.

Provider:34: attempt to concatenate a nil value (field 'op')
stack traceback:
	Provider:34: in method 'getItem'
	Floater:40: in method 'fetchMessage'
	Floater:80: in method 'startCrawl'
	Floater:68: in method 'runCrawl'
	GameRunner:295: in method 'runCrawl'
	GameRunner:89: in method 'createLevel'
	Main:18: in function 'setup'

The assert found a line in the array without an op. Of course it did, because the initial crawl isn’t compatible.

        assert(false, "unexpected item in Provider array "..(item.op or "no op"))

GameRunner has this:

function GameRunner:initialCrawl()
    return { "Welcome to the Dungeon.",
    "Here you will find great adventure,",
    "fearsome monsters, and fantastic rewards.",
--    {self, "gotHere", 10, "bar" },
    "Some of you may die, but I am willing",
    "to make that sacrifice."}
end

I’d like CombatOperation to help me out here. Let’s do a quick test and implementation:

        _:test("display", function()
            local player = FakeEntity("Princess")
            local monster = FakeEntity("Spider")
            local co = CombatOperation(player, monster)
            local op = co:display("This text")
            _:expect(op.op).is("display")
            _:expect(op.text).is("This text")
        end)

This demands:

function CombatOperation:display(aString)
    return {op="display", text=aString}
end

The test runs. We fix GameRunner:

function GameRunner:initialCrawl()
    local co = CombatOperation()
    return { co:display("Welcome to the Dungeon."),
    co:display("Here you will find great adventure,"),
    co:display("fearsome monsters, and fantastic rewards."),
    co:display("Some of you may die, but I am willing"),
    co:display("to make that sacrifice.")}
end

This kind of calls for displayArray, doesn’t it? But not yet. Let’s see if this flies.

The initial crawl works, but when I attack a serpent, I get the atacks message ok but then this walkback:

Provider:23: attempt to index a nil value (local 'receiver')
stack traceback:
	Provider:23: in method 'execute'
	Provider:32: 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'

We haven’t got quite the thing here. I think we’ve failed to put our operation results into the crawl. This brings me to another concern which I’ll mention when I get this to work:

The current issue is here, where we execute the command but don’t add it in:

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:execute(item)
    else
        assert(false, "unexpected item in Provider array "..(item.op or "no op"))
    end
    return self:getItem()
end

So:

    elseif item.op == "op" then
        self.addItems(self:execute(item))

Let’s try that for size.

Hm same message. A nil receiver.

Provider:23: attempt to index a nil value (local 'receiver')
stack traceback:
	Provider:23: in method 'execute'
	Provider:32: 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'

So we did get ‘op’ but no receiver. What does our attack do? Let’s review the test:

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

Sure looks like this should be working. Let’s dump the table in execute, see what’s going in.

Hm well, reading the code helps:

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

Missed the r in receiver.

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

Testing … well, I get a new error:

attempt to index a nil value
stack traceback:
	[C]: in for iterator 'for iterator'
	Provider:13: in field 'addItems'
	Provider:32: 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'

That looks to me to be like “not an array”. Ah. execute doesn’t return its result.

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

This should work. Remind me to speak of the other concern.

It still doesn’t work. I really should have committed a while back. Now I’m stuck with running tests and uncommitted decent code, but the plugging in hasn’t worked.

Some tracing:

function Provider:execute(command)
    local receiver = command.receiver
    local method = command.method
    local arg1 = command.arg1
    local arg2 = command.arg2
    print("execute ", receiver, method)
    local t = receiver[method](receiver, arg1, arg2)
    for i,v in ipairs(t) do
        print(i,v)
    end
    return t
end

That gives me this:

execute 	CombatOperation	attemptHit
1	table: 0x28f289540
attempt to index a nil value
stack traceback:
	[C]: in for iterator 'for iterator'
	Provider:13: in field 'addItems'
	Provider:37: 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'

So the call to attemptHit returns a table, with one element in it, a table. This, I’d have thought, was a one-element array with a display in it. I’ll drill down:

    for k,v in pairs(t[1]) do
        print(k,v)
    end
execute 	CombatOperation	attemptHit
1	table: 0x28f082e80
op	display
text	Princess whacks Serpent!

attempt to index a nil value
stack traceback:
	[C]: in for iterator 'for iterator'
	Provider:13: in field 'addItems'
	Provider:40: 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'

Review the code in Provider, again. I’m missing something.

function Provider:addItems(array)
    for i,v in ipairs(array) do
        table.insert(self.items, v)
    end
end

Sheesh! Look at this:

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

Standard error whatever number it is: dot for :.

    elseif item.op == "op" then
        self:addItems(self:execute(item))
    else

whack

And it works as intended. I really wish Lua would help me with that common mistake. Anyway, I think we’re done for the morning, as it is now 1302. (We went to the store for shopping as well as chai, so I started late. And ran over a bit anyway.)

Let’s commit this as working, and sum up: CombatOperation now in place. Combat very limited.

Summary

So. With a lot of help from my friends, the idea of doing a CombatOperation object that produces items to be provided to the crawl by the Provider has appeared.

The individual operations seem, so far, to be easy to test, and we don’t need to consider much in the way of combinatorics. We’ll need to do some kind of randomness injection as we go forward, but it seems like a good start. And we do have a fake random number generator to use if we need it.

So, we’re on, what, our third or fourth approach to doing combat in coordination with the Crawl, but it’s getting close and closer, and, it seems to me, more and more clear.

We moved from a coroutine that yielded text lines, to a scheme that put tables into the array for execution, to a smaller object that does single conceptual entries (perhaps with more than one element), and with a table that looks more like a real “operation” with an opcode and such.

I suspect we may turn this into a class, but because the various ops have inherently different notions of their contents, we’ll wait and see. We’ll certainly want little construction methods if nothing else.

I think this is progress. If you agree, or don’t, tweet me up!

See you next time!


D2.zip