More thinking and doing on combat encounters. Can we avoid turn-based?

The Encounter object is working out nicely, even lending itself, so far, to being reasonably testable. I’d like to move today to including blocking and perhaps riposte. And I have an idea. (Danger, Will Robinson, warning warning.)

I’ve mentioned the floating message idea from the Codea SimpleDungeon demo program that I have. A short text message floats up from above a player (or monster), fading as it rises. We need some way to express the flow of an encounter, who strikes first, whether the attack is avoided, and so on. My idea is that if we cause the Encounter to produce messages like that, we can test the sequence of messages to determine that it has done the right thing. Sort of a poor man’s mock object.

I’m already faking the random number generator, to ensure that I get the action I’m trying to test. I plan to fake a sort of “console” to which the Encounter writes. Or, I might just have it accrue a string or array of lines, and interrogate that. The array of lines seems like a good idea, in that we could usually just check how many lines there are, or even just check the last line as we go along.

Yes. Array of lines it is. Let’s review encounter and start putting in lines.

function Encounter:attack()
    local attackerSpeed = self:rollRandom(self.attacker:speed())
    local defenderSpeed = self:rollRandom(self.defender:speed())
    if attackerSpeed >= defenderSpeed then
        local damage = self:rollRandom(self.attacker:strength())
        if damage > 0 then
            self.defender:damageFrom(self.attacker.tile, damage)
        end
    end
end

I’ll just add a message array in init and a way to add messages to it.

function Encounter:addMessage(aMessage)
    table.insert(self.messages,aMessage)
end

I hate that name already: it’s too long for frequent use. Change to log:

function Encounter:log(aMessage)
    table.insert(self.messages,aMessage)
end

OK, now let’s enhance a test and make it work.

Ah. Thinking about that makes me realize that I can’t expect to look at the last message in the middle of the Encounter. An Encounter runs all at once, so all the messages will be logged by the time attack returns. We may want to delay that somehow, for reasons of gameplay. That would also require us to stop time, I think. For now, we’ll just see what’s in the array.

I think this test should go as shown in the log checks:

        _:test("Encounter with player faster", function()
            randomNumbers = {3, 2, 1}
            local mtile = Tile:room(10,10, runner)
            local monster = Monster(mtile,runner,Monster:getMtEntry(1))
            local ptile = Tile:room(11,10, runner)
            local player = Player(ptile,runner)
            local encounter = Encounter(player, monster, random)
            _:expect(encounter.attacker).is(player)
            _:expect(encounter.defender).is(monster)
            _:expect(monster:health()).is(1)
            encounter:attack()
            _:expect(monster:health()).is(0)
            local log = encounter.messages
            _:expect(log).has("Player attacks Pink Slime")
            _:expect(log).has("Player strikes first")
            _:expect(log).has("Player does 1 damage")
            _:expect(log).has("Pink Slime is dead")
        end)

This should be amusing. Running the test, we get four messages like this:

1: Encounter with player faster  -- Actual: table: 0x280417700, Expected: Player attacks Pink Slime

CodeaUnit’s “has” check is pretty weak, but when the value is there it’ll return OK.

Now let’s make the first check run.

function Encounter:attack()
    self:log(self.attacker:name().." attacks "..self.defender:name())
    local attackerSpeed = self:rollRandom(self.attacker:speed())
    local defenderSpeed = self:rollRandom(self.defender:speed())
    if attackerSpeed >= defenderSpeed then
        local damage = self:rollRandom(self.attacker:strength())
        if damage > 0 then
            self.defender:damageFrom(self.attacker.tile, damage)
        end
    end
end

I think that player doesn’t know name. I’m wrong, it does and the name it returns is “Princess”. Change the test.

            _:expect(log).has("Princess attacks Pink Slime")
            _:expect(log).has("Princess strikes first")
            _:expect(log).has("Princess does 1 damage")
            _:expect(log).has("Pink Slime is dead")

Run. Expect the strikes first to be the first error. And it is. Fix that. I went ahead and added two messages:

function Encounter:attack()
    self:log(self.attacker:name().." attacks "..self.defender:name())
    local attackerSpeed = self:rollRandom(self.attacker:speed())
    local defenderSpeed = self:rollRandom(self.defender:speed())
    if attackerSpeed >= defenderSpeed then
        self:log(self.attacker:name().." strikes first")
        local damage = self:rollRandom(self.attacker:strength())
        if damage > 0 then
            self:log(self.attacker:name().." does "..damage.." damage")
            self.defender:damageFrom(self.attacker.tile, damage)
        end
    end
end

Run …

1: Encounter with player faster  -- Actual: table: 0x2904d1780, Expected: Pink Slime is dead

So that’s good. I guess we’ll just have to inquire as to status.

function Encounter:attack()
    self:log(self.attacker:name().." attacks "..self.defender:name())
    local attackerSpeed = self:rollRandom(self.attacker:speed())
    local defenderSpeed = self:rollRandom(self.defender:speed())
    if attackerSpeed >= defenderSpeed then
        self:log(self.attacker:name().." strikes first")
        local damage = self:rollRandom(self.attacker:strength())
        if damage > 0 then
            self:log(self.attacker:name().." does "..damage.." damage")
            self.defender:damageFrom(self.attacker.tile, damage)
            if self.defender:isDead() then
                self:log(self.defender:name().." is dead")
            end
        end
    end
end

I don’t think entities know isDead but they should. So when this comes up I’ll fix it.

3: Encounter with equal speed, player wins -- Encounter:23: attempt to call a nil value (method 'isDead')

I’ll put that on both Player and Monster.

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

And similarly for player.

Explanation

You may be wondering why I decided not just to say not self.defender:isAlive(), and why I implemented the method as not self:isAlive() instead of not self.alive.

I did the first because I prefer code that says what it means as directly as possible. I accept more “not” logic than I probably should, and this morning my settings were more toward better expression.

As for the second, once there is a method that amounts to accessing a member variable, I prefer to use it than to put in another access to the member. The cost is low, the clarity is at least as good, and if and when the member variables change around, the methods are more likely to continue to work.

Matter of taste. That’s mine. YTMV, but think about how you balance expressiveness and other values.

Back to work.

Run the tests.

Our first log test runs as expected. Now with the monster faster … our flow is less complete but we can work much the same way. Here’s our test, which is not reflecting a full encounter.

        _:test("Encounter with monster faster", function()
            randomNumbers = {2, 3, 1}
            local mtile = Tile:room(10,10, runner)
            local monster = Monster(mtile,runner,Monster:getMtEntry(1))
            local ptile = Tile:room(11,10, runner)
            local player = Player(ptile,runner)
            local encounter = Encounter(player, monster, random)
            _:expect(encounter.attacker).is(player)
            _:expect(encounter.defender).is(monster)
            _:expect(monster:health()).is(1)
            encounter:attack()
            _:expect(monster:health()).is(1)
        end)

One nice thing about the log: it is a nice way of documenting what we expect the flow to be, and it will guide checks against entity state and such as well.

I’m noticing that the random number lists are getting harder to understand. They have to be crafted with the particular flow of the Encounter in mind. Right now I don’t see a good way of dealing with that. We’ll press on.

        _:test("Encounter with monster faster", function()
            randomNumbers = {2, 3, 1}
            local mtile = Tile:room(10,10, runner)
            local monster = Monster(mtile,runner,Monster:getMtEntry(1))
            local ptile = Tile:room(11,10, runner)
            local player = Player(ptile,runner)
            local encounter = Encounter(player, monster, random)
            _:expect(encounter.attacker).is(player)
            _:expect(encounter.defender).is(monster)
            _:expect(monster:health()).is(1)
            encounter:attack()
            local log = encounter.messages
            _:expect(monster:health()).is(1)
            _:expect(player:health()).is(3)
            _:expect(log).has("Princess attacks Pink Slime")
            _:expect(log).has("Pink Slime strikes first")
            _:expect(log).has("Pink Slime does 2 damage")
            _:expect(#log).is(3)
        end)

I think that’s what I expect. Run to get the error which will be the second log item if I’m not mistaken.

2: Encounter with monster faster  -- Actual: table: 0x2905b3b40, Expected: Pink Slime strikes first
function Encounter:attack()
    self:log(self.attacker:name().." attacks "..self.defender:name())
    local attackerSpeed = self:rollRandom(self.attacker:speed())
    local defenderSpeed = self:rollRandom(self.defender:speed())
    if attackerSpeed >= defenderSpeed then
        self:log(self.attacker:name().." strikes first")
        local damage = self:rollRandom(self.attacker:strength())
        if damage > 0 then
            self:log(self.attacker:name().." does "..damage.." damage")
            self.defender:damageFrom(self.attacker.tile, damage)
            if self.defender:isDead() then
                self:log(self.defender:name().." is dead")
            end
        end
    else
        self:log(self.defender:name().." strikes first")
    end
end

I foresee some duplication coming up. Let it roll.

2: Encounter with monster faster  -- Actual: table: 0x298efcdc0, Expected: Pink Slime does 2 damage

Our random numbers are going to be wrong right about now. We’ll deal with that as it happens.

function Encounter:attack()
    self:log(self.attacker:name().." attacks "..self.defender:name())
    local attackerSpeed = self:rollRandom(self.attacker:speed())
    local defenderSpeed = self:rollRandom(self.defender:speed())
    if attackerSpeed >= defenderSpeed then
        self:log(self.attacker:name().." strikes first")
        local damage = self:rollRandom(self.attacker:strength())
        if damage > 0 then
            self:log(self.attacker:name().." does "..damage.." damage")
            self.defender:damageFrom(self.attacker.tile, damage)
            if self.defender:isDead() then
                self:log(self.defender:name().." is dead")
            end
        end
    else
        self:log(self.defender:name().." strikes first")
        local damage = self:rollRandom(self.defender:strength())
        if damage > 0 then
            self:log(self.defender:name().." does "..damage.." damage")
            self.attacker:damageFrom(self.defender.tile, damage)
            if self.attacker:isDead() then
                self:log(self.attacker:name().." is dead")
            end
        end
    end
end

I think this might work except for the random numbers,

2: Encounter with monster faster  -- Actual: table: 0x284010700, Expected: Pink Slime does 2 damage

Yep. Change the “random” numbers.

        _:test("Encounter with monster faster", function()
            randomNumbers = {2, 3, 2}

And the test runs. This is going very nicely. Let’s commit: Encounter handles either entity taking first blow based on speed roll.

However, the Encounter code is getting messy, and it seems to me that it is slated to get more messy as more paths open up. We can at least remove this duplication:

function Encounter:attack()
    self:log(self.attacker:name().." attacks "..self.defender:name())
    local attackerSpeed = self:rollRandom(self.attacker:speed())
    local defenderSpeed = self:rollRandom(self.defender:speed())
    if attackerSpeed >= defenderSpeed then
        self:log(self.attacker:name().." strikes first")
        local damage = self:rollRandom(self.attacker:strength())
        if damage > 0 then
            self:log(self.attacker:name().." does "..damage.." damage")
            self.defender:damageFrom(self.attacker.tile, damage)
            if self.defender:isDead() then
                self:log(self.defender:name().." is dead")
            end
        end
    else
        self:log(self.defender:name().." strikes first")
        local damage = self:rollRandom(self.defender:strength())
        if damage > 0 then
            self:log(self.defender:name().." does "..damage.." damage")
            self.attacker:damageFrom(self.defender.tile, damage)
            if self.attacker:isDead() then
                self:log(self.attacker:name().." is dead")
            end
        end
    end
end

A simple Extract Method can do that, with both entities as parameters:

function Encounter:attack()
    self:log(self.attacker:name().." attacks "..self.defender:name())
    local attackerSpeed = self:rollRandom(self.attacker:speed())
    local defenderSpeed = self:rollRandom(self.defender:speed())
    if attackerSpeed >= defenderSpeed then
        self:firstAttack(self.attacker, self.defender)
    else
        self:log(self.defender:name().." strikes first")
        local damage = self:rollRandom(self.defender:strength())
        if damage > 0 then
            self:log(self.defender:name().." does "..damage.." damage")
            self.attacker:damageFrom(self.defender.tile, damage)
            if self.attacker:isDead() then
                self:log(self.attacker:name().." is dead")
            end
        end
    end
end

function Encounter:firstAttack(attacker, defender)
    self:log(attacker:name().." strikes first")
    local damage = self:rollRandom(attacker:strength())
    if damage > 0 then
        self:log(attacker:name().." does "..damage.." damage")
        defender:damageFrom(attacker.tile, damage)
        if defender:isDead() then
            self:log(defender:name().." is dead")
        end
    end
end

Run the tests. Still good. Complete the refactoring:

function Encounter:attack()
    self:log(self.attacker:name().." attacks "..self.defender:name())
    local attackerSpeed = self:rollRandom(self.attacker:speed())
    local defenderSpeed = self:rollRandom(self.defender:speed())
    if attackerSpeed >= defenderSpeed then
        self:firstAttack(self.attacker, self.defender)
    else
        self:firstAttack(self.defender, self.attacker)
    end
end

function Encounter:firstAttack(attacker, defender)
    self:log(attacker:name().." strikes first")
    local damage = self:rollRandom(attacker:strength())
    if damage > 0 then
        self:log(attacker:name().." does "..damage.." damage")
        defender:damageFrom(attacker.tile, damage)
        if defender:isDead() then
            self:log(defender:name().." is dead")
        end
    end
end

Tests run. Commit: refactoring Encounter:attack().

Let’s Reflect

We’re at a handy stopping point. We’re generating this cool log as a sort of play by play reporting on an encounter. We might be able to come up with some nice way to scroll this onto the screen, or we might pause the game to display the whole report. Or there might be some other clever scheme.

I’m feeling the need to settle that fairly soon.

More concerning is that the logic here seems to me to be going to get kind of messy, and it’s making me think in terms of complicated solutions. I’m going to need to hold myself back from that, because complicated solutions should be used only when they’re needed … and when we understand what’s needed. Yes, we could build some kind of strange multi-state battle object right now, with a little language for expressing how battles should go … but we’d surely put things in that were never needed and leave things out that we’d discover the first time we used it.

So let’s not do that. But we do need to make sure that our encounter remains clear, and if we can’t make it clear in procedural code, we have to do something.

One possibility is to create sub-encounter objects of some kind. Instead of calling a firstAttack method, we could create an object that just manages one attack. Then there might be other objects to deal with other matters. We’ll see. I’m going to try really hard to do it procedurally, and to keep it clean.

Let’s get back to it and deal with blocking. This, I think, is going to make us change all our random number tables. Life’s tough. Roughly what I want is this:

  • Princess attacks Pink Slime
  • Princess strikes first
  • Pink Slime avoids strike

Well, let’s just ask for that and then make it happen. It should happen based entirely on the random numbers we provide.

I notice that all my encounter tests are using the same setup. I’m tempted to make it part of before but maybe not yet.

        _:test("defender can avoid strike", function()
            randomNumbers = {3, 2, 1}
            local mtile = Tile:room(10,10, runner)
            local monster = Monster(mtile,runner,Monster:getMtEntry(1))
            local ptile = Tile:room(11,10, runner)
            local player = Player(ptile,runner)
            local encounter = Encounter(player, monster, random)
            encounter:attack()
            _:expect(monster:health()).is(1)
            _:expect(log).has("Princess attacks Pink Slime")
            _:expect(log).has("Princess strikes first")
            _:expect(log).has("Pink Slime avoids strike")
        end)

The first two random numbers are princess speed and slime speed, so she’ll strike first. The third is her attempted damage against the slime. One will do. We’ll need another number soon … but first I want to figure out what I need.

Running the test so far we get this fail:

4: defender can avoid strike -- CodeaUnit:94: bad argument #1 to 'for iterator' (table expected, got nil)

That surprises me until I realize that I didn’t read the log.

Let’s change Encounter:attack() to return the log. Won’t bother anyone else, will make me happy.

Now we get:

4: defender can avoid strike  -- Actual: table: 0x28c1f8c40, Expected: Pink Slime avoids strike

Great. When we implement this, all the other encounter tests will break looking for a new random number. That’s OK, we expect it. Here’s our current firstAttack code:

function Encounter:firstAttack(attacker, defender)
    self:log(attacker:name().." strikes first")
    local damage = self:rollRandom(attacker:strength())
    if damage > 0 then
        self:log(attacker:name().." does "..damage.." damage")
        defender:damageFrom(attacker.tile, damage)
        if defender:isDead() then
            self:log(defender:name().." is dead")
        end
    end
end

The effect I’ve asked for is for the strike to be avoided before the damage is rolled. We could have done it the other way, but I don’t see a reason to do that. So … here’s my attempt:

~~~luafunction Encounter:firstAttack(attacker, defender) self:log(attacker:name()..” strikes first”) local attackerSpeed = self:rollRandom(attacker:speed()) local defenderSpeed = self:rollRandom(defender:speed()) if defenderSpeed > attackerSpeed then self:log(defender:name()..” avoids strike”) else local damage = self:rollRandom(attacker:strength()) if damage > 0 then self:log(attacker:name()..” does “..damage..” damage”) defender:damageFrom(attacker.tile, damage) if defender:isDead() then self:log(defender:name()..” is dead”) end end end end ~~~

I need two more random numbers in my test, attacker speed roll vs defender roll, defender larger. The other tests will fail running out of numbers. Here’s my list:

I expect this to work.

4: defender can avoid strike  -- OK

The others do fail. They want NOT to avoid the strike, so they need two new numbers before their last, the third greater than the fourth.

I provide those (read the code later if you want to see them) and all the tests run. Commit: defender can avoid strike.

Red, Green, REFACTOR, remember?

I’m of a mind to call it a morning here. Let’s see about that code though.

function Encounter:firstAttack(attacker, defender)
    self:log(attacker:name().." strikes first")
    local attackerSpeed = self:rollRandom(attacker:speed())
    local defenderSpeed = self:rollRandom(defender:speed())
    if defenderSpeed > attackerSpeed then
        self:log(defender:name().." avoids strike")
    else
        local damage = self:rollRandom(attacker:strength())
        if damage > 0 then
            self:log(attacker:name().." does "..damage.." damage")
            defender:damageFrom(attacker.tile, damage)
            if defender:isDead() then
                self:log(defender:name().." is dead")
            end
        end
    end
end

That’s not very symmetrical. How about this refactoring:

function Encounter:firstAttack(attacker, defender)
    self:log(attacker:name().." strikes first")
    local attackerSpeed = self:rollRandom(attacker:speed())
    local defenderSpeed = self:rollRandom(defender:speed())
    if defenderSpeed > attackerSpeed then
        self:log(defender:name().." avoids strike")
    else
        self:attackStrikes(attacker,defender)
    end
end

function Encounter:attackStrikes(attacker,defender)
    local damage = self:rollRandom(attacker:strength())
    if damage > 0 then
        self:log(attacker:name().." does "..damage.." damage")
        defender:damageFrom(attacker.tile, damage)
        if defender:isDead() then
            self:log(defender:name().." is dead")
        end
    end
end

And now, for clarity:

function Encounter:firstAttack(attacker, defender)
    self:log(attacker:name().." strikes first")
    local attackerSpeed = self:rollRandom(attacker:speed())
    local defenderSpeed = self:rollRandom(defender:speed())
    if defenderSpeed > attackerSpeed then
        self:attackMisses(attacker,defender)
    else
        self:attackStrikes(attacker,defender)
    end
end

function Encounter:attackMisses(attacker, defender)
    self:log(defender:name().." avoids strike")
end

Yummy. That’s nice and symmetrical and even expressive. I think we need to encapsulate the rolling logic but we’ll deal with that another day.

Commit: clean up Encounter:firstAttack().

Summing Up

This is going rather nicely. Our message log acts much as a very smart and sophisticated test double might, essentially amounting to a trace of the path the code takes. But we actually have a use for our log, and we didn’t have to build anything fancy.

I am still wondering about how we’ll use this facility. Right now, it all takes place in a zillionth of a second. If we imagine floating messages, they should probably float up at short intervals, perhaps every half second or so, one after another. But if that were to be done, we should really stop all action until the encounter is over. Otherwise, while the report is still scrolling, other monsters, or even the same monster, could attack, the player could attack again or run away … anything could happen.

The usual solution to this is to have the game be entirely turn-based, where the player does something, then the monsters do something, and so on. But I rather like the way the monsters move around while the Princess dithers about what to do.

I suppose we could make the battle turn-based in the sense that it would pop up a battle screen, and you’d tap it for your next action. Maybe there’d even be more than one thing to do, like poke with a stick, slash with a sword, throw a fireball, whatever. That could fall out of starting with a pause to display the results, perhaps with a popup window of the encounter console, and a place to touch to continue the game.

We’ll deal with that in due time. I do think that the next step should be to display the Encounter info to the human player, since it will be at least interesting.

Next time, maybe today, maybe tomorrow. See you then!


D2.zip