Dungeon 90
Let’s see how far we can push this ‘little language’ we’re making for combat. I should try something tricky next, to be sure the idea can bear weight.
We do seem to be producing a little language here, with operation codes and parameters. I don’t think we’ll get to the point of allowing it to generate loops and perhaps not even conditionals, so it won’t be a Turing complete language. But it’s a language, nonetheless.
The basic idea, as I understand it1, this little language is executed from one end of a double-ended queue (deque)2. Operations enter the queue from at least two sources. The first, kicking off a sequence, is from somewhere outside the CombatOperation. Someone creates a CombatOp and stuffs it into the queue.
The other source is from executing CombatOps. Each op is expected to return an array of CombatOps, perhaps empty. Any ops that it returns are appended to the other end of the deque. So a given op, say an attack, can determine that damage needs to be done, and can append the requisite subtraction of points, which will be done when that op turns up at the other end of the deque.
This is the third or fourth cut I’ve made at putting in enough intelligence to perform the kind of combat I want in the game, where the monsters can do fairly sophisticated attacks, where there can be more than just two characters in the fight, and where the player can choose options during the battle.
I found the first few cuts unsatisfactory for various reasons, including that sometimes I couldn’t see a decent way to test them without more pain than I care to endure. More commonly, however, I just couldn’t quite see the steps to make them work. I choose not to pause at such a time and design some “perfect” grand solution. I want always to proceed in fairly small steps, a couple of hours work, and to see whether I ever get boxed in and have to tear out and replace big solutions before I can put in some sensible next feature.
I do this because as programmers we are often taught that we have to design before we build, and that approach simply cannot work in real software development, where the development goes on for months and years, adding capability long after the first version has some out. We simply must learn to work incrementally, because the bosses get really angry when we come in and tell them that that next feature is going to require a huge rewrite of the Frammis.
Anyway, here we are on the nth version of a “solution” to combat. I think we should try to stress the idea a bit, to check whether it’ll do everything we need.
What Do We Need
I see some issues that seem potentially difficult, and that are important.
- Current version is player only
- We need to give the monsters a go after the player goes. One possibility is just to dump a suitable first op into the queue at the end of player’s go. But then what if monsters go first? Should we dump a player op automatically? That would loop. Is that what we want?
- More than one monster
- We want more than one monster to be able to join the fray. I see at least two possibilities. One is that we could have the monster “field” in the CombatOp be a collection. Might even be a great opportunity for the Composite pattern, whee. Another possibility is that if another monster shows up, if can just toss a CombatOp into the queue on its own behalf. But that could lead to odd things, interleaving monster operations and player ones:
-
“Princess hits Serpent; Vampire bites Princess; Serpent takes 4 damage; Princess contemplates blood cocktail for dinner; etc.”
-
We could imagine keeping the array sorted somehow, so that all the ops coming from the first one are completed before any further ones. Or we could have a separate queue that is only read when the first one goes empty. Somehow, we need to deal with the multi-monster situation.
- Short termination
- Suppose there’s a monster attack in the deque while another attack runs. The running attack kills the Princess. The second attack will look pretty petty if it goes ahead wailing on the Princess’s dead body. Give her a break, guys. But at the time the attack was queued, the Princess was alive. So there will need to be some special checking to allow for short-terminating ops. I expect this not to be too difficult but will need to try it.
There’s nothing for it but to try some of these things. It’s tempting to wait until the current player combat sequence is more robust, but I think there’s not too much learning there. So let’s see what we can do about giving the monster a whack.
Monster Attacks
The current CombatOperation class is actually fairly agnostic about who’s attacking and who’s taking the blows:
function CombatOperation:init(player,monster)
self.player = player
self.monster = monster
end
function CombatOperation:__tostring()
return "CombatOperation"
end
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
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
function CombatOperation:display(aString)
return {op="display", text=aString}
end
We can make it more agnostic by changing the member names to attacker
and defender
. Let’s take a chance and do that. We’ll even test it.
_:test("monster attack", function()
local result
local i,r
local defender = FakeEntity("Princess")
local attacker = FakeEntity("Spider")
local co = CombatOperation(attacker, defender)
result = co:attack()
i,r = next(result,i)
_:expect(r.text).is("Spider attacks Princess!")
end)
I expect this to work, modulo typos. And, modulo typos, it does.
Let’s rename the vars.
function CombatOperation:init(attacker,defender)
self.attacker = attacker
self.defender = defender
end
And so on throughout. I noticed this:
function CombatOperation:attemptHit()
local result = {}
local msg = string.format("%s whacks %s!", self.attacker:name(), self.defender:name())
table.insert(result, { op="display", text = msg } )
return result
end
function CombatOperation:display(aString)
return {op="display", text=aString}
end
We wrote that nice convenience method, display
, that builds a display operation for us. Let’s use it in our own code:
function CombatOperation:attack()
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
function CombatOperation:attemptHit()
local result = {}
local msg = string.format("%s whacks %s!", self.attacker:name(), self.defender:name())
table.insert(result, self:display(msg) )
return result
end
function CombatOperation:display(aString)
return {op="display", text=aString}
end
When we have provided a method for some purpose, even a use by “outsiders”, I think it’s a good practice to use it even internally. If we don’t, we have duplication, and where there is duplication there is the prospect of updating some elements of the duplication and not noticing others. Folding them together into the common method pays off often enough that I prefer to do it when I notice.
Let’s pretend that attemptHit
is the end of the attacker’s side of the sequence, and that now we want the defender to get a turn. Naively, can’t we just put a new CombatOperation into our result, with attacker and defender reversed?
That will lead to an infinite loop, but that’s OK for now. Let’s try it:
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 co = CombatOperation(self.defender, self.attacker)
table.insert(result, co:attack())
return result
end
Those two lines ahead of the return are new. Let’s see what happens.
What happens is this:
Provider:36: unexpected item in Provider array no op
stack traceback:
[C]: in function 'assert'
Provider:36: 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'
I guess I didn’t get back what I expected. I’ve noticed that the various returns and usages of the CombatOp aren’t consistent. Sometimes I need to wrap with table brackets, sometimes not, and so on. Let’s remember to try to normalize the calls a bit.
What’s going on here is that co:attack
is returning a collection of operations making up attack. In our method, attamptHit
, we have a collection of our own going. We need not to return a collection of collections, but to append the returned collection to our own.
Naively, let’s try this:
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 co = CombatOperation(self.defender, self.attacker)
local ops = co:attack()
for i,o in ipairs(ops) do
table.insert(result,o)
end
return result
end
We’ll need to do better, but first let’s make it work. And it does:
We loop, presumably forever, whacking back and forth at each other.
Now, I think that what we’d like is for the second op, triggered at the end of the first, not to trigger a third.
Let’s see how we might do that. One simple way would be to tell the new CombatOp not to trigger followup. Let’s try that for size. If we wind up not liking it, we can improve it.
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 co = CombatOperation(self.defender, self.attacker)
co:noRepeats()
local ops = co:attack()
for i,o in ipairs(ops) do
table.insert(result,o)
end
return result
end
We can implement noRepeats
like this:
function CombatOperation:init(attacker,defender)
self.attacker = attacker
self.defender = defender
self.repeats = true
end
function CombatOperation:noRepeats()
self.repeats = false
end
function CombatOperation:attemptHit()
local result = {}
local msg = string.format("%s whacks %s!", self.attacker:name(), self.defender:name())
table.insert(result, self:display(msg) )
if self.repeats then
local co = CombatOperation(self.defender, self.attacker)
co:noRepeats()
local ops = co:attack()
for i,o in ipairs(ops) do
table.insert(result,o)
end
end
return result
end
This should now just give each entity one turn, and it does:
Let’s refactor a bit and then commit this.
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:otherTurn(result)
return result
end
function CombatOperation:otherTurn(result)
if not self.repeats then return result end
local co = CombatOperation(self.defender, self.attacker)
co:noRepeats()
local ops = co:attack()
table.move(ops,1,#ops, #result+1,result)
end
Here we just call a new function otherTurn
, which either adds a new CombatOp or doesn’t. I used the Lua table.move
function to append the return table elements to our result table, on the grounds that I don’t know how many items the co:attack
may return.
The way things are going here, I’m starting to think we need some kind of running accumulator variable into which we can just stuff things. For now, I’m just going to notice that. I don’t see right now how best to do it.
I think we’re good for a commit here. But no, there are two floater tests failing now. Let’s see what’s up there.
2: floater initialize -- Provider:36: unexpected item in Provider array no op
3: floater pulls messages appropriately -- Provider:36: unexpected item in Provider array no op
_:test("floater initialize", function()
local lines = { "Message 1", "Message 2", "Message 3", "Message 4", "Message 5" }
local fl = Floater(nil, 50, 25, 4)
fl:runCrawl(lines)
_:expect(fl.yOffsetStart).is(50)
_:expect(fl.lineSize).is(25)
_:expect(fl.lineCount).is(4)
_:expect(fl:linesToDisplay()).is(1)
_:expect(fl:yOffset()).is(50)
fl:increment(25)
_:expect(fl:yOffset()).is(75)
_:expect(fl:linesToDisplay()).is(2)
end)
Yes, well, those lines are now wrong, aren’t they? We need display ops in there. Of course I’m not just loving the fact that this code is going to run the crawl, but it should be harmless.
_:test("floater initialize", function()
local co = CombatOperation(nil,nil)
local lines = { co:display("Message 1"), co:display("Message 2"), co:display("Message 3"), co:display("Message 4"), co:display("Message 5") }
...
Now that one runs. Probably the other is similar. Yes. Easily fixed. Commit: monster gets a whack at Princess after Princess attacks.
It’s 1006. Let’s sum up and print this baby.
Summary
Today’s rather simple bit of code answers a couple of important concerns. First, we’ve shown that we can initiate a counter-attack from inside an attack, and second, we’ve shown that we can keep it from issuing another counter-counter-attack after that.
I wish I had thought of the word “counter-attack” when I was doing this. In fact, it’s so good I’m tempted to put it in now.
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
function CombatOperation:counterAttack(result)
if not self.repeats then return result end
local co = CombatOperation(self.defender, self.attacker)
co:noRepeats()
local ops = co:attack()
table.move(ops,1,#ops, #result+1,result)
end
Commit: rename otherTurn to counterAttack.
Now, as I was saying, these two things turned out to be simple and easy, but they are significant, and give me a lot more confidence that this CombatOperation notion will carry the necessary weight. We have more to do, of course, but I’m feeling better about it than I have about the other angles.
Sometimes it takes a while to get things in decent shape. We’ve spent perhaps five quarter- to half-days on this. Certainly less than a week of work, and we’ve had running code to ship every day, almost always better than the day before.
I’m feeling good. Great time to stop and relax, while Jekyll gins up the web site.
See you next time!
-
Seriously, as I understand it. We don’t always know exactly what we’ve created. There are many ways of seeing the structure and meaning of code. One of them is the one we had in mind when we wrote it, but there are others. ↩
-
A deque, pronounced “deck” by the in crowd, is a double-ended queue. In principle it allows addition or removal at either end. In our usage today, we remove from one end and add to the other, which makes it arguably a FIFO. ↩