Let’s finish up and clean the coroutine message floater, and purge the unused tabs. Then make all the tests run. Then, stick our head up and look around.

A quick review of my yellow sticky notes offers some things that may need doing:

  • Clean up Floater, especially co-routine creation;
  • Start and stop Monsters during Floater run;
  • Some tests are turned off;
  • Big red text for tests not running;
  • Allow Floater to accept ordinary arrays;
  • Allow Floater to accept a new input function while one is still running: append;

I’ve sorted those into my current priority order. At the moment, we don’t need the Floater to accept arrays, nor do we have a need for it to accept a new input while still running the old one. I do think we’ll need that at some point, but I generally try not to put in those kinds of generalizations until they are actually needed. The specificity of an actual need helps me focus on what’s really needed.

Let’s review how we’re using the Floater first. It’s a bit odd:

When the game starts, we do this:

function GameRunner:createLevel(count)
    self:createRandomRooms(count)
    self:connectRooms()
    self:convertEdgesToWalls()
    local r1 = self.rooms[1]
    local rcx,rcy = r1:center()
    local tile = self:getTile(vec2(rcx,rcy))
    self.player = Player(tile,self)
    self.monsters = self:createThings(Monster,3)
    for i,monster in ipairs(self.monsters) do
        monster:startAllTimers()
    end
    self.keys = self:createThings(Key,5)
    self:createThings(Chest,5)
    self.buttons = {}
    table.insert(self.buttons, Button("left",100,200, 64,64, asset.builtin.UI.Blue_Slider_Left))
    table.insert(self.buttons, Button("up",200,250, 64,64, asset.builtin.UI.Blue_Slider_Up))
    table.insert(self.buttons, Button("right",300,200, 64,64, asset.builtin.UI.Blue_Slider_Right))
    table.insert(self.buttons, Button("down",200,150, 64,64, asset.builtin.UI.Blue_Slider_Down))
    --self:addMessages(self:initialMessages())
    self.floater = Floater(coroutine.wrap(self.initialCrawl),50,25,4)
end

We actually set a new Floater into the GameRunner member variable. We used to have a FloatingMessageList there, and we reused it for new messages.

We also have a special new function, initialCrawl:

function GameRunner:initialCrawl()
    coroutine.yield("Welcome to the Dungeon.")
    coroutine.yield("Here you will find great adventure,")
    coroutine.yield("fearsome monsters, and fantastic rewards.")
    coroutine.yield("Some of you may die, but I am willing")
    coroutine.yield("to make that sacrifice.")
    coroutine.yield("")
end

This is a simple transformation of the original initialMessages that is still here but not used:

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

This is duplication and we are commanded to remove it. We could do so by removing the initialMessages function, but I think we’d do better to return the messages as an array of text lines there, and loop over them in the other function. Like this:

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

function GameRunner:initialCrawl()
    for i,m in ipairs(self:initialMessages()) do
        coroutine.yield(m)
    end
    coroutine.yield("")
end

Ah! No. This won’t quite work. We need to use initialCrawl as a naked function, so it cannot reference self. Revert.

Let’s see about cleaning up the rest of this just a bit. In GameRunner:init, we have this:

function GameRunner:init()
    self.tileSize = 64
    self.tileCountX = 85 -- if these change, zoomed-out scale 
    self.tileCountY = 64 -- may also need to be changed.
    self.tiles = {}
    for x = 1,self.tileCountX+1 do
        self.tiles[x] = {}
        for y = 1,self.tileCountY+1 do
            local tile = Tile:edge(x,y, self)
            self:setTile(tile)
        end
    end
    self.floater = FloatingMessageList(self)
end

function GameRunner:addMessages(anArray)
    self.floater:addMessages(anArray)
end

This won’t do, at least as written, because we’re clobbering the floater that is used for battle.

RED ALERT!!! This means that yesterday’s release will blow up on a battle, trying to add messages to the old floater, while the new one doesn’t understand that idea. We have to do a quick fix.

function GameRunner:init()
    self.tileSize = 64
    self.tileCountX = 85 -- if these change, zoomed-out scale 
    self.tileCountY = 64 -- may also need to be changed.
    self.tiles = {}
    for x = 1,self.tileCountX+1 do
        self.tiles[x] = {}
        for y = 1,self.tileCountY+1 do
            local tile = Tile:edge(x,y, self)
            self:setTile(tile)
        end
    end
    self.floater = FloatingMessageList(self)
    self.cofloater = nil
end

We’ll add a new member variable, cofloater, and nil it. When we start the game we’ll use it:

    self.fofloater = Floater(self,coroutine.wrap(self.initialCrawl),50,25,4)

(I’m also passing the runner in to the Floater, because it needs it, as we’ll see in a moment.

Now in GameRunner:drawMessages:

function GameRunner:drawMessages()
    pushMatrix()
    self:scaleForLocalMap()
    self.floater:draw()
    popMatrix()
end

We want to draw the new floater if there is one:

function GameRunner:drawMessages()
    pushMatrix()
    self:scaleForLocalMap()
    self.floater:draw()
    if self.cofloater then self.cofloater:draw() end
    popMatrix()
end

And in Floater:

function Floater:init(runner, provider, yOffsetStart, lineSize, lineCount)
    self.runner = runner
    self.provider = provider
    self.yOffsetStart = yOffsetStart
    self.lineSize = lineSize
    self.lineCount = lineCount
    self.yOff = self.yOffsetStart
    self.buffer = {}
    self.stopFetching = false
    self:fetchMessage()
end

function Floater:draw()
    pushStyle()
    fill(255)
    fontSize(20)
    local y = self:yOffset()
    local pos = self.runner:playerGraphicCenter()
    pos = pos + vec2(0,self.yOff)
    for i = 1,self:numberToDisplay() do
        text(self.buffer[i], pos.x, pos.y)
        pos = pos - vec2(0,self.lineSize)
    end
    self:increment()
    popStyle()
end

I’ve put in the new runner member variable and used it. I think that will allow the crawl to run and battles to work. I’m apparently mistaken, however. Also my tests broke. That’s easily fixed, just pass in a nil runner during testing.

But why has the crawl not displayed? Ah, a typo “fofloater”1

    self.cofloater = Floater(self,coroutine.wrap(self.initialCrawl),50,25,4)

That works, and battles work. Commit: fixed battles not working.

Bad cess, though, I released a version that didn’t work. Customer support has begun poking needles into my doll over in their department. Ow.

Now, though, let’s see about using the new encounter stuff for the battle. To review:

function createEncounter(player,monster,random)
    f = function()
        local player = player
        local monster = monster
        local random = random or math.random
        attack(player, monster, random)
    end
    return coroutine.wrap(f)
end

This is supposed to be enough to do the job, if we hammer a Floater into GameRunner, containing this new stuff. However, the existing floater is still there and running. We should make it remove itself:

I think that should be done here:

function Floater:increment(n)
    self.yOff = self.yOff + (n or 1)
    if self:linesToDisplay() > self.lineCount then
        table.remove(self.buffer,1)
        self.yOff = self.yOff - self.lineSize
    end
    if #self.buffer < self:linesToDisplay() then
        self:fetchMessage()
    end
end

If, after we remove a line from the array, it is empty, then we have consumed the last line of the messages, and can readily remove the cofloater. An alternative would be to leave it and have it come back to life if we provide a new function. That might pay off in the longer term, if we even need to append a sequence of messages without waiting for the first sequence to finish.

In fetchMessage we do this:

function Floater:fetchMessage()
    if self.stopFetching then return end
    local msg = self.provider()
    if msg == "" then
        self.stopFetching = true
    else
        table.insert(self.buffer, msg)
    end
end

At this moment, the coroutine is still alive: it has not run off the end. And I’m kind of sorry that I used coroutine.wrap, because now I can’t readily interrogate the coroutine to see if it is dead.

Let’s change that decision: there’s really only one user right now.

    self.cofloater = Floater(self, self.initialCrawl, 50,25,4)

Now Floater receives a function that is expected to be a coroutine with yields in it, and we can change how we use it internally:

function Floater:init(runner, provider, yOffsetStart, lineSize, lineCount)
    self.runner = runner
    self.provider = coroutine.create(provider)
    self.yOffsetStart = yOffsetStart
    self.lineSize = lineSize
    self.lineCount = lineCount
    self.yOff = self.yOffsetStart
    self.buffer = {}
    self.stopFetching = false
    self:fetchMessage()
end
function Floater:fetchMessage()
    if coroutine.status(self.provider) == "dead" then return end
    local tf, msg = coroutine.resume(self.provider)
    if tf and msg ~= "" and msg ~= nil then
        table.insert(self.buffer, msg)
    end
end

Now if the coroutine is dead we stop fetching. After a while, the buffer array runs out and the draw starts drawing nothing:

function Floater:draw()
    pushStyle()
    fill(255)
    fontSize(20)
    local y = self:yOffset()
    local pos = self.runner:playerGraphicCenter()
    pos = pos + vec2(0,self.yOff)
    for i = 1,self:numberToDisplay() do
        text(self.buffer[i], pos.x, pos.y)
        pos = pos - vec2(0,self.lineSize)
    end
    self:increment()
    popStyle()
end

It will still try to draw. We could remove it from the drawing code but instead let’s just make the exit a bit quicker:

function Floater:draw()
    local n = self:numberToDisplay()
    if n == 0 then return end
    pushStyle()
    fill(255)
    fontSize(20)
    local y = self:yOffset()
    local pos = self.runner:playerGraphicCenter()
    pos = pos + vec2(0,self.yOff)
    for i = 1,n do
        text(self.buffer[i], pos.x, pos.y)
        pos = pos - vec2(0,self.lineSize)
    end
    self:increment()
    popStyle()
end

The initial crawl works with this in place, but the tests do not. I’m not sure why that would be. Here’s part of the test:

        _:test("floater pulls messages appropriately", function()
            local fl = Floater(nil,msg,50,25,4)
            _:expect(#fl.buffer).is(1)
            _:expect(fl.buffer[1]).is("Message 1")
            fl:increment(24)
            _:expect(#fl.buffer).is(1)
            _:expect(fl.buffer[1]).is("Message 1")
            fl:increment()
            _:expect(#fl.buffer).is(2) -- <-----
            _:expect(fl.buffer[2]).is("Message 2")
            fl:increment(25)
            _:expect(fl.buffer[3]).is("Message 3")
            fl:increment(25)
            _:expect(fl.buffer[4]).is("Message 4")
        

We’re failing on the indicated line, checking for 2. And we fail thereafter.

Let’s read the code, see what we’ve broken:

function Floater:increment(n)
    self.yOff = self.yOff + (n or 1)
    if self:linesToDisplay() > self.lineCount then
        table.remove(self.buffer,1)
        self.yOff = self.yOff - self.lineSize
    end
    if #self.buffer < self:linesToDisplay() then
        self:fetchMessage()
    end
end

function Floater:fetchMessage()
    if coroutine.status(self.provider) == "dead" then return end
    local tf, msg = coroutine.resume(self.provider)
    if tf and msg ~= "" and msg ~= nil then
        table.insert(self.buffer, msg)
    end
end

I’m going to resort to printing what’s going on.

function Floater:fetchMessage()
    if coroutine.status(self.provider) == "dead" then return end
    local tf, msg = coroutine.resume(self.provider)
    print("fetch ", tf, msg)
    if tf and msg ~= "" and msg ~= nil then
        table.insert(self.buffer, msg)
    end
end

This comes out four times:

3: floater pulls messages appropriately  -- OK

Then this, once:

3: floater pulls messages appropriately  -- Actual: 1, Expected: 2

And there is no intervening print. Either we didn’t call fetch, or our coroutine is dead. The latter seems impossible, so I’ll check.

function Floater:fetchMessage()
    if coroutine.status(self.provider) == "dead" then print("dead"); return end
    local tf, msg = coroutine.resume(self.provider)
    print("fetch ", tf, msg)
    if tf and msg ~= "" and msg ~= nil then
        table.insert(self.buffer, msg)
    end
end

Sure enough, it’s returning dead. How can this be? What is the status of a coroutine that has never yet been called? It is “suspended”. I’ve just checked that.

Ah. I changed how Floater works and didn’t fix this:

        _:before(function()
            msg = coroutine.wrap(messages)
        end)

We want:

        _:before(function()
            msg = messages
        end)

Because we don’t want it wrapped any more. We could refer directly to messages in the test, of course. Anyway, now the tests and the crawl both run.

I want to change the logic a bit, however, so that we don’t have to return the “” message from our coroutines, instead letting the code rely on the final return.

Recall from my old coroutine test how things work:

        _:test("coroutine", function()
            local co = coroutine.create(generate)
            tf,val = coroutine.resume(co)
            _:expect(tf).is(true)
            _:expect(val).is(1)
            tf,val = coroutine.resume(co)
            _:expect(val).is(2)
            tf,val = coroutine.resume(co)
            _:expect(val).is(3)
            tf,val = coroutine.resume(co)
            _:expect(val).is(4)
            tf,val = coroutine.resume(co)
            _:expect(val).is(5)
            _:expect(coroutine.status(co)).is("suspended")
            tf,val = coroutine.resume(co)
            _:expect(tf).is(true)
            _:expect(val).is(nil)
            _:expect(coroutine.status(co)).is("dead")
            tf,val = coroutine.resume(co)
            _:expect(tf).is(false)
            _:expect(val).is("cannot resume dead coroutine")
        end)

The caller (sender of resume) cannot know that it has received the last item that will be yielded, until it asks for one too many. On the return from asking for one too many, the return values are tf=true, val=nil. If you call again, on the now-dead coroutine, you’ll get tf=false, val=”cannot resume…”.

In our code, we ignore blank messages, but we expect them. Let’s change our rules so that you don’t have to send the blank message, the Floater will deal with running off the end. (If you do send a blank message, we’ll display it.)

function GameRunner:initialCrawl()
    coroutine.yield("Welcome to the Dungeon.")
    coroutine.yield("Here you will find great adventure,")
    coroutine.yield("fearsome monsters, and fantastic rewards.")
    coroutine.yield("Some of you may die, but I am willing")
    coroutine.yield("to make that sacrifice.")
end

I expect this to continue to work, but I’ll change the Floater to accept “” if it does come through.

function Floater:fetchMessage()
    if coroutine.status(self.provider) == "dead" then return end
    local tf, msg = coroutine.resume(self.provider)
    if tf and msg ~= nil then
        table.insert(self.buffer, msg)
    end
end

Crawl works, tests fail, whazzup with that?

3: floater pulls messages appropriately  -- Actual: 4, Expected: 3

It appears that the contents checks are right but the buffer sizes have gone wrong.

Ah, I’ve accepted the blank line and the test is still providing one.

function messages()
    for i = 1,5 do
        coroutine.yield("Message "..i)
    end
end

So a bit of revision to those tests and we no longer expect a blank line. I check to see what the new encounter coroutine does and it doesn’t produce an extra blank line anyway.

I guess that’s good.

Now I’d like to see if I can plug the new encounter coroutine into the battle between player and monster. We now have a cofloater which is sitting with a dead coroutine, doing nothing. If we give it a live one, it should come back to life.

Let’s put a method on GameRunner, runCrawl(), that takes a new function and gives it to the existing floater.

function GameRunner:runCrawl(aFunction)
    self.cofloater:runCrawl(aFunction)
end

function Floater:runCrawl(aFunction)
    self.provider = coroutine.create(aFunction)
end

Let’s test this by changing GameRunner to use it to run the initial crawl. To do that, we need an empty Floater in init. I think we can hammer that in:

    self.cofloater = Floater(self, function() end, 50,25,4)

And then, I presume:

...
    table.insert(self.buttons, Button("left",100,200, 64,64, asset.builtin.UI.Blue_Slider_Left))
    table.insert(self.buttons, Button("up",200,250, 64,64, asset.builtin.UI.Blue_Slider_Up))
    table.insert(self.buttons, Button("right",300,200, 64,64, asset.builtin.UI.Blue_Slider_Right))
    table.insert(self.buttons, Button("down",200,150, 64,64, asset.builtin.UI.Blue_Slider_Down))
    --self:addMessages(self:initialMessages())
    self:runCrawl(self.initialCrawl)
end

The crawl does not start. I am disappoint.

I think we have to kick things off. So:

function Floater:init(runner, provider, yOffsetStart, lineSize, lineCount)
    self.runner = runner
    self.provider = coroutine.create(provider)
    self.yOffsetStart = yOffsetStart
    self.lineSize = lineSize
    self.lineCount = lineCount
    self:startCrawl()
end

function Floater:startCrawl()
    self.yOff = self.yOffsetStart
    self.buffer = {}
    self:fetchMessage()
end

function Floater:runCrawl(aFunction)
    self.provider = coroutine.create(aFunction)
    self:startCrawl()
end

That’s better. The crawl runs. I’m fairly sure that if we were to start the Floater on the battle crawl, that would work as well. First we’d best commit: crawl converted to reuse and coroutine orientation.

The morning has been a bit chaotic. I’ve been hammering away at the code. I’ve made decent use of the tests, in a sort of reactive way, but the changes I’ve bene making, while quick and mostly confident, have been a bit choppy. I feel like I’m banging away, not smoothly progressing toward a goal.

Fortunately, my dear wife needs something scanned, so I’ll use that as a break.

I am really fired up to patch this in and try the new battle. But let’s take a look and see how close we are to what we really need.

We’re now using a single Floater instance, that is created with an empty function, so it will run to completion instantly. We have a new method, startCrawl that starts a new crawl, and a new method runCrawl that sets up a new one and starts it.

That seems pretty clean to me. Just for fun, let’s see what it takes to use the new coroutine Encounter. Here’s where we anticipate starting it:

function createEncounter(player,monster,random)
    f = function()
        local player = player
        local monster = monster
        local random = random or math.random
        attack(player, monster, random)
    end
    return coroutine.wrap(f)
end

We no longer expect a wrapped coroutine. That was a nice idea but it turned out to be better to check for dead than to use an indirect measure of whether the coroutine was exhausted. So that last line should return the function:

function createEncounter(player,monster,random)
    f = function()
        local player = player
        local monster = monster
        local random = random or math.random
        attack(player, monster, random)
    end
    return f
end

Now when it’s time for a battle, currently this happens:

function Monster:startActionWithPlayer(aPlayer)
    if aPlayer:isDead() then return end
    Encounter(self,aPlayer):attack()
end

And similarly with the player. It seems to me that all we need to do now is this:

function Monster:startActionWithPlayer(aPlayer)
    if aPlayer:isDead() then return end
    --Encounter(self,aPlayer):attack()
    self.runner:runCrawl(createEncounter(self,aPlayer))
end

And similarly with player. However, this seems not to work. What happens is that the first message come out: “Pink Slime attacks Princess!”, but no more messages.

OK, I’m going to revert this last bit and report what I “know”. And I think we’ll stop for the day, as it is 11 AM.

What I “Know”

The second messages didn’t come out because the battle coroutine errored. Its error was from here:

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

That is, of course, the fake random number generator that is used during testing. It’s called here:

function rollRandom(aNumber)
    return random(0,aNumber)
end

That value, “random” is supposed to be initialized here:

function createEncounter(player,monster,random)
    f = function()
        local player = player
        local monster = monster
        local random = random or math.random
        attack(player, monster, random)
    end
    return coroutine.wrap(f)
end

Now I expected that the local random here would be the one we find. It seems that it is not in view as an upvalue, as I thought it was, and that instead we’re finding the global one, which is just inconveniently named random. If I were to rename that as “fakeRandom” I think we’d get some different error, probably an inability to find random at all.

I will do that now. I do get a new and better error, from the tests this time:

2: coroutine encounter, player faster -- TestEncounter:53: EncounterCoroutines:67: attempt to call a nil value (global 'random')

I think perhaps I need to move those locals outside the initial function. And I think I only need random, really. The change I make doesn’t work:

function createEncounter(player,monster,random)
    local random
    f = function()
        --local player = player
        --local monster = monster
        random = random or math.random
        attack(player, monster, random)
    end
    return coroutine.wrap(f)
end

Do you remember me saying that coroutines are pretty deep in the bag of tricks, and did I mention that I’ve not used them much if at all, in recent decades? Obviously I don’t understand quite how they work.

I was thinking that “upvalues” are looked up dynamically. That seems not to be the case. They are merely lexically accessible, that is, the anonymous function there in createEncounter can see random but the functions it calls cannot. Makes sense now that I say it. So, what’s to do?

Well, we started passing the random function down. We could continue that. That will surely work for us here, if we just have everyone pass the function down to rollRandom.

I think that’s our only hope. It means we have to pass it all the way down.

-- Encounter Coroutines
-- RJ 20210111

local yield = coroutine.yield

function createEncounter(player,monster,random)
    local random
    f = function()
        --local player = player
        --local monster = monster
        random = random or math.random
        attack(player, monster, random)
    end
    return coroutine.wrap(f)
end

function attack(attacker, defender, random)
    yield(attacker:name().." attacks ".. defender:name().."!")
    local attackerSpeed = rollRandom(attacker:speed(), random)
    local defenderSpeed = rollRandom(defender:speed(), random)
    if attackerSpeed >= defenderSpeed then
        yield(attacker:name().." is faster!")
        firstAttack(attacker,defender, random)
    else
        yield(defender:name().." is faster!")
        firstAttack(defender,attacker. random)
    end
end

function firstAttack(attacker,defender, random)
    yield(attacker:name().." strikes!")
    local attackerSpeed = rollRandom(attacker:speed(), random)
    local defenderSpeed = rollRandom(defender:speed(), random)
    if defenderSpeed > attackerSpeed then
        attackMisses(attacker,defender, random)
        if math.random() > 0.5 then
            yield("Riposte!!")
            firstAttack(defender,attacker, random)
        end
    else
        attackStrikes(attacker,defender, random)
    end
end

function attackMisses(attacker, defender, random)
    yield(defender:name().." avoids strike!")
end

function attackStrikes(attacker,defender, random)
    local damage = rollRandom(attacker:strength(), random)
    if damage == 0 then
        yield("Weak attack! ".. defender:name().." takes no damage!")
        if math.random() > 0.5 then
            yield("Riposte!!")
            firstAttack(defender,attacker, random)
        end
    else
        yield(attacker:name().." does "..damage.." damage!")
        defender:damageFrom(attacker.tile, damage)
        if defender:isDead() then
            yield(defender:name().." is dead!")
        end
    end
end


function rollRandom(aNumber, random)
    return random(0,aNumber)
end

This appears to work. Let’s trim the create:

function createEncounter(player,monster,random)
    f = function()
        attack(player, monster, random or math.random)
    end
    return coroutine.wrap(f)
end

Tests are running. Still haven’t plugged the coroutine encounter into the game.

First, commit: changed handling of random in coroutine encounter.

Now, let’s try plugging it in again, just as before, to see if it works, or why it doesn’t.

This project is dragging on. We need to get it under control or cut it loose.

function Monster:startActionWithPlayer(aPlayer)
    if aPlayer:isDead() then return end
    --Encounter(self,aPlayer):attack()
    self.runner:runCrawl(createEncounter(self,aPlayer))
end

function Player:startActionWithMonster(aMonster)
    if aMonster:isDead() then return end
    --Encounter(self,aMonster):attack()
    self.runner:runCrawl(createEncounter(self,aMonster))
end

(The method just above was still there. It looks like the revert may not have worked. Anyway, let’s see what happens now.

It nearly works. I see at least two problems:

First, since there is no stopMonsters call happening, and since the crawl just starts a new function when asked to, you see a lot of truncated attack reports, when the monster attacks while the crawl is still running.

Second, quite often after “Somebody strikes”, there is no result from the strike. Let’s look at the code for that one:

function firstAttack(attacker,defender, random)
    yield(attacker:name().." strikes!")
    local attackerSpeed = rollRandom(attacker:speed(), random)
    local defenderSpeed = rollRandom(defender:speed(), random)
    if defenderSpeed > attackerSpeed then
        attackMisses(attacker,defender, random)
        if math.random() > 0.5 then
            yield("Riposte!!")
            firstAttack(defender,attacker, random)
        end
    else
        attackStrikes(attacker,defender, random)
    end
end

function attackMisses(attacker, defender, random)
    yield(defender:name().." avoids strike!")
end

function attackStrikes(attacker,defender, random)
    local damage = rollRandom(attacker:strength(), random)
    if damage == 0 then
        yield("Weak attack! ".. defender:name().." takes no damage!")
        if math.random() > 0.5 then
            yield("Riposte!!")
            firstAttack(defender,attacker, random)
        end
    else
        yield(attacker:name().." does "..damage.." damage!")
        defender:damageFrom(attacker.tile, damage)
        if defender:isDead() then
            yield(defender:name().." is dead!")
        end
    end
end

It appears to me that if it says “strikes”, it must then soon print something else. I wonder if an error is occurring. Let’s try a trick in Floater:

function Floater:fetchMessage()
    if coroutine.status(self.provider) == "dead" then return end
    local tf, msg = coroutine.resume(self.provider)
    if tf then
        if msg ~= nil then table.insert(self.buffer, msg) end
    else
        table.insert(self.buffer, "error: "..msg)
    end
end

With this in place, I think any error messages from the coroutine will scroll in the crawl.

And I do get a message:

error: EncounterCoroutines:64: attempt to call a nil value (local 'random')

Apparently someone needs random and didn’t get it. Unfortunately, we do not get the traceback from an error in a coroutine: they are run protected.

Looking at the bigger picture, this is potentially a more serious problem than just finding this defect. If a coroutine can error out, the game will do bad things and we won’t get any indication. Basically it will look like the coroutine just terminated normally, but early. We can ensure that we at least log an error, of course, doing something like the print I stuck in above.

It may be that this idea, marvelous though it may be, is too sexy for its shirt.

At this moment, I don’t even see the bug. I do know how to fix it, but not in a way that I like.

function rollRandom(aNumber, random)
    return (random or math.random)(0,aNumber)
end

But someone has passed a nil in here.

Found it:

        firstAttack(defender,attacker. random)

That’s a dot, not a comma.

With that change, the game works. We’re missing the stopMonsters logic, however. First, commit: fix defect causing encounter coroutine to short-terminate.

I’m going to call it a day, it’s about lunch time, so I am well into overtime. I’ll sum up …

Summing Up … however …

I’m going to need to reflect on the issue of errors in coroutines. That makes their use more dangerous than one might like, even though we can clearly cause them to display an error indication, or log it, or email it to us, or whatever.

That aside, my work today was pretty fast and loose. It was a bit like carpentry by eye instead of measuring and leveling. Things went in place, and they went into place well, but I wasn’t being as smooth and careful as I would like to think I usually am.

I do think we’re in a pretty good place, but I feel that I got there by being a good programmer with poor habits. I’d much prefer to work as a good programmer with good habits, and today, somehow, didn’t feel like that.

The mistake with the random numbers is especially interesting. The issue was actually caused by my desire to test things by providing known “random” numbers, so that I could drive the conditions in the encounter to desired results. I did that by optionally passing in a random number function, and otherwise (I thought) using the usual math.random.

Owing to an additional misunderstanding on my part, I thought storing that value in a local would act like storing it in a member variable would have, and that was not the case.

The result was the need to pass random all around in the coroutine. A better solution to the test requirement might be to have a well-known location to roll random numbers, which would normally contain math.random and might, during testing, contain fakeRandom.

The upshot is, I guess, that the new coroutine encounter does seem to be working as intended, and that there are some issues to consider, especially whether this power tool is a good choice or too risky. With it working, it’ll be hard to condemn it as too risky, and right now I don’t see an alternative that’s substantially better.

Until next time, we’ll let it ride. See you then!


D2.zip

  1. banana fana fo floater …