Let’s do an experiment with a Finite State Machine, to get a sense of whether we want one.

The standard solution to an AI for a game like ours often turns out to be a “finite state machine”, which is a powerful way of representing complicated behavior. It’s not as powerful as full-on “Turing complete” programming, but in practice it can do a lot and it can be mostly defined in tables, which is convenient.

A search for LUA FSM turns up a few items, notably this one from Kyle Conroy, and this one, from unindented. Both of these are based on an FSM implementation in JavaScript, by jakesgordon.

All three of these are pretty comprehensive, and while we could probably figure out how they work and probably translate them into our local programming style, I think it will be more enjoyable to create our own, while borrowing freely from the ideas of these fine individuals above.

The essence of a finite state machine (FSM) can be expressed in a table. The FSM has a number of states. It can be in one and only one state at a time. When events occur, the table describes how the FSM will respond to the event, typically by doing to a new state. If that was all there was, there wouldn’t be much use for the things, but we’ll allow specification of things to be done upon entering a new state, upon leaving an old one, and so on, as we need to get what we want.

It is common in FSM work to allow a given state to have a set of “sub-states”, essentially an embedded FSM that operates in the given super-state until finally it signals that it’s time to go to another super-state.

Take our Horn Girl as an example. She says three things when you first meet her, in response to the ?? button. When you use an Amulet of Cat Persuasion in her presence, she says “Thanks”.

We could express her table this way:

Event From To Action
?? W1 W2 “Hello, I have a Quest”
?? W2 W3 “Looking for ACP”
?? W3 W1 “Reward”
use ACP W1 H1 “Thanks!”
use ACP W2 H1 “Thanks!”
use ACP W3 H1 “Thanks!”



So long as we just type ??, Horn Girl will cycle her messages. We could, of course, take her to a fourth W state where she just said “I have spoken” or something. Cycling is how it works now.

Because she can be in any of states W1, W2, or W3 when we give her the ACP, we had to write a state transition for each case.

If, instead, we had a sub-state for W, that sub-state could do the cycling of the message, but allow us to write just one transition for ?? and one for “use ACP” at the top level.

As is our fashion, we’ll not be assuming that we’ll implement sub-states, but if the need arises, we’ll see how easy or hard it is to add them, and one way or the other, with any luck, we’ll learn something.

The articles mentioned above define their FSMs with tables, such as:

local alert = fsm.create({
  initial = "green",
  events = {
    {name = "warn",  from = "green",  to = "yellow"},
    {name = "panic", from = "yellow", to = "red"   },
    {name = "calm",  from = "red",    to = "yellow"},
    {name = "clear", from = "yellow", to = "green" }
  }
})

The implementation style there is not one that we commonly use, but it’s common in Lua. A global table is created, fsm in this case, and given various functions, such as create, and whatever internal ones it needs. Here’s what the “unindented” FSM document says about what create does:

This will create an object with a method for each event:

  • alert.warn() - causes the machine to transition from green to yellow
  • alert.panic() - causes the machine to transition from yellow to red
  • alert.calm() - causes the machine to transition from red to yellow
  • alert.clear() - causes the machine to transition from yellow to green along with the following:
  • alert.current - contains the current state
  • alert.is(s) - returns true if state s is the current state
  • alert.can(e) - returns true if event e can be fired in the current state
  • alert.cannot(e) - returns true if event e cannot be fired in the current state
  • alert.transitions() - returns the list of events that are allowed from the current state

Those seem like pretty reasonable things for a general-purpose FSM generator to be able to do. We might or might not want all those.

Tests!

I just noticed that both the Kyle Conroy and the unindented implementations include tests, in a style similar to CodeaUnit, although a bit different. They are certainly readable and would be easy to convert to our style. Props to both these folks.

We will, of course, build our thing with TDD. In fact … I was going to do a bit more speculative design here, but let’s instead do it in the presence of tests.

I’m starting a new Codea project for this, mostly just to keep the tab noise away on my screen. We start with the standard framework:

-- RJ 2021066
-- FSM and tests

function testFSM()
    CodeaUnit.detailed = true

    _:describe("FSM - Finite State Machine", function()

        _:before(function()
        end)

        _:after(function()
        end)

        _:test("HOOKUP", function()
            _:expect("Foo", "testing fooness").is("Bar")
        end)
        
        _:test("Floating point epsilon", function()
            _:expect(1.45).is(1.5, 0.1)
        end)

    end)
end

Run to be sure we’re hooked up. We are. Remove the floating point test and change the other to be our first test.

I start with a little sketch of what our table might look like:

        _:test("Create FSM", function()
            local tab = {
                { event="go", from="s1", to="s2" },
                { event="back", from="s2", to="s1" },
            }
            local fsm = FSM:create(tab)
        end)

To actually build that FSM would be a pretty big bite for one test. I have no intention of doing that. Let’s see. The FSM should be able to tell us its state. We’ll take a page from one of the examples and say that if we don’t provide an initial state, it will be “none”.

        _:test("Create FSM", function()
            local tab = {
                { event="go", from="s1", to="s2" },
                { event="back", from="s2", to="s1" },
            }
            local fsm = FSM:create(tab)
            _:expect(fsm:state()).is("none")
        end)

This fails on create of course. No, actually, on FSM. We don’t even have the class yet. See why I write these?

The error requires me to write this:

FSM = class()

I get the error:

1: Create FSM -- Tests:20: attempt to call a nil value (method 'create')

So I need this create. Except, no, I think I don’t want create, I want to just put the table into the FSM as if I were creating a normal object, which I am. Change the test:

        _:test("Create FSM", function()
            local tab = {
                { event="go", from="s1", to="s2" },
                { event="back", from="s2", to="s1" },
            }
            local fsm = FSM(tab)
            _:expect(fsm:state()).is("none")
        end)

Now we can expect to fail looking for state:

1: Create FSM -- Tests:21: attempt to call a nil value (method 'state')

Fake it till you make it:

function FSM:state()
    return "none"
end

Test runs. We’re going to want an FSM under test in many tests, so we’ll probably create a standard one in before, but it’s too soon.

Do we think we want to check whether our FSM can or cannot accept a given event? I’m not sure. Let’s extend our table to include an initial state and test again:

        _:test("Create FSM", function()
            local tab = {
                { event="go", from="s1", to="s2" },
                { event="back", from="s2", to="s1" },
            }
            local fsm = FSM(tab)
            _:expect(fsm:state()).is("none")
            tab.initial = "s1"
            fsm = FSM(tab)
            _:expect(fsm:state()).is("s1")
        end)

This’ll fail with “none” instead of “s1”.

1: Create FSM  -- Actual: none, Expected: s1

Now we have to do a bit of real implementation.

function FSM:init(tab)
    self.tab = tab
    self.state = tab.initial or "none"
end

function FSM:state()
    return self.state
end

I expect the test to run. It doesn’t, because I can’t have a method and a member of the same name. Dammit.

function FSM:init(tab)
    self.tab = tab
    self.fsm_state = tab.initial or "none"
end

function FSM:state()
    return self.fsm_state
end

NOW it better run. And it does.

Let’s extend this test further, letting it tell a story:

        _:test("Create FSM", function()
            local tab = {
                { event="go", from="s1", to="s2" },
                { event="back", from="s2", to="s1" },
            }
            local fsm = FSM(tab)
            _:expect(fsm:state()).is("none")
            tab.initial = "s1"
            fsm = FSM(tab)
            _:expect(fsm:state()).is("s1")
            _:expect(fsm:can("go"),"does she go?").is(true)
            _:expect(fsm:cannot("back"), "back").is(true)
        end)

I’ve decided to borrow the idea of can and cannot, because at least they are useful for testing. I plan to make this work, and then to refactor this test a bit.

Test fails asking for `can”, of course:

1: Create FSM -- Tests:25: attempt to call a nil value (method 'can')

Hm, now how might we do this? We could munch the table to get a list of all the events for our current state. Or we could mumble er do something about all the states.

Let’s write this out longhand at least to begin with.

function FSM:can(event)
    for i,trans in ipairs(self.tab) do
        if trans.from == self.fsm_state then
            if trans.event == event then
                return true
            end
        end
    end
    return false
end

I actually think this might just work. The transitions are stored as array elements, and the keyed element initial won’t show up in ipairs. I’ll run it to find out what I’ve done wrong.

1: Create FSM does she go? -- OK
1: Create FSM -- Tests:26: attempt to call a nil value (method 'cannot')

Take that, doubters!

Now cannot seems easy …

function FSM:cannot(event)
    return not self:can(event)
end

And the tests all run.

First, I want to split out my first one, and use the table that includes initial in the before. Here’s the whole deal:

-- RJ 2021066
-- FSM and tests

function testFSM()
    CodeaUnit.detailed = true
    
    local fsm

    _:describe("FSM - Finite State Machine", function()

        _:before(function()
            local tab = {
                initial = "s1",
                { event="go", from="s1", to="s2" },
                { event="back", from="s2", to="s1" },
            }
            fsm = FSM(tab)
        end)

        _:after(function()
        end)

        _:test("FSM with no initial starts at none", function()
            local tab = {
                { event="go", from="s1", to="s2" },
                { event="back", from="s2", to="s1" },
            }
            local my_fsm = FSM(tab)
            _:expect(my_fsm:state()).is("none")
        end)
        
        _:test("FSM can and cannot", function()
            _:expect(fsm:state()).is("s1")
            _:expect(fsm:can("go"),"does she go?").is(true)
            _:expect(fsm:cannot("back"), "back").is(true)
        end)
        
    end)
end

I gave that first test its own FSM my_fsm for a bit of clarity.

In other news, I have typed FMS several times now. It has never been helpful.

Now I guess we want to send an event to our FSM and have it change state:

        _:test("FSM changes state", function()
            _:expect(fsm:state()).is("s1")
            fsm:event("go")
            _:expect(fsm:state()).is("s2")
        end)

Test should fail looking for event.

3: FSM changes state -- Tests:40: attempt to call a nil value (method 'event')

Forgive me for implementing it now:

function FSM:event(event)
    for i,trans in ipairs(self.tab) do
        if trans.from == self.fsm_state and trans.event == event then
            self.fsm_state = trans.to
            return
        end
    end
end

Tests run. Extend that last one a bit further.

        _:test("FSM changes state", function()
            _:expect(fsm:state()).is("s1")
            fsm:event("go")
            _:expect(fsm:state()).is("s2")
            fsm:event("back")
            _:expect(fsm:state()).is("s1")
        end)

I expect this to run. It does. We have implemented the system so that if you give the FSM an event that the current state does not understand, it stays in the state it’s in without demur. Let’s document that:

        _:test("FSM changes state", function()
            _:expect(fsm:state()).is("s1")
            fsm:event("go")
            _:expect(fsm:state()).is("s2")
            fsm:event("back")
            _:expect(fsm:state()).is("s1")
            fsm:event("unknown")
            _:expect(fsm:state()).is("s1")
        end)

Still runs. I see something similar to duplication:

function FSM:can(event)
    for i,trans in ipairs(self.tab) do
        if trans.from == self.fsm_state then
            if trans.event == event then
                return true
            end
        end
    end
    return false
end

function FSM:event(event)
    for i,trans in ipairs(self.tab) do
        if trans.from == self.fsm_state and trans.event == event then
            self.fsm_state = trans.to
            return
        end
    end
end

Watch this (2)

Let’s make them more similar. I like the way I did the second one, so let’s make the first one like that.

function FSM:can(event)
    for i,trans in ipairs(self.tab) do
        if trans.from == self.fsm_state and trans.event == event then
            return true
        end
    end
    return false
end

Let’s make them even more alike. Let’s have the event one return true or false depending on whether it did or didn’t change the state.

function FSM:can(event)
    for i,trans in ipairs(self.tab) do
        if trans.from == self.fsm_state and trans.event == event then
            return true
        end
    end
    return false
end

function FSM:event(event)
    for i,trans in ipairs(self.tab) do
        if trans.from == self.fsm_state and trans.event == event then
            self.fsm_state = trans.to
            return true
        end
    end
    return false
end

Wow, these are really similar. There’s just that one line different.

What if we did this:

function FSM:performAccepted(event, f)
    for i,trans in ipairs(self.tab) do
        if trans.from == self.fsm_state and trans.event == event then
            if f then f(trans) end
            return true
        end
    end
    return false
end

And then this:

function FSM:can(event)
    return self:performAccepted(event)
end

I think this would work. Do the tests agree? They do. Then this:

function FSM:event(event)
    return self:performAccepted(event, function(trans)
        self.fsm_state = trans.to
    end)
end

Do the tests agree with me? In fact they do.

We have eliminated the duplication by first making more of it and then moving the loop to a new method, and parameterizing it.

We could have just passed a flag, saying do or do not change state, but I felt passing the function was nearly as clear, and it’s certainly a bit more general. YAGNI, but it was in my fingers so I typed it.

I think it’s past time to set up Working Copy to let me version this FSM.

Setting Up Working Copy

I’m going to repeat this process here, because I do it just seldom enough that I forget how and have to search old articles to relearn it.

  1. Create new repo in Working Copy: FSM.
  2. In the Repo list, long-press FSM.
  3. Select Share.
  4. Select Setup Package Sync (Codea logo)
  5. Select FSM.codea from the list of all Codea projects.
  6. Do initial commit.

There, I feel more safe now.

Moving Right Along

Now we probably want to make our real FSMs do some things, and that brings us back to the list of capabilities from the “unindented” one:

This will create an object with a method for each event:

  • alert.warn() - causes the machine to transition from green to yellow
  • alert.panic() - causes the machine to transition from yellow to red
  • alert.calm() - causes the machine to transition from red to yellow
  • alert.clear() - causes the machine to transition from yellow to green along with the following:
  • alert.current - contains the current state
  • alert.is(s) - returns true if state s is the current state
  • alert.can(e) - returns true if event e can be fired in the current state
  • alert.cannot(e) - returns true if event e cannot be fired in the current state
  • alert.transitions() - returns the list of events that are allowed from the current state

I don’t think we will want our FSM to have methods for each event. I think we’ll continue to use the event(xyz) notation. I could be wrong, and we’ll find out when we try to use the thing.

But we do know a bit about what we want to have happen with Horn Girl.

First, I think of her as having only two real states, sad because she has lost her amulet, and happy because she has it. However, when she is sad, and we query her, she says a different thing each time, up to three times. Let’s codify that, having her say these things:

  1. I am your friendly neighborhood Horn Girl! And do I have a super quest for you!
  2. I am looking for my lost Amulet of Cat Persuasion, so that I can persuade the Cat Girl to let me pet her kitty.
  3. If you can find it and bring it to me, I shall repay you generously.
  4. Please find the Amulet and return it to me.

We’ll just let her say that last thing over and over.

What I’d like to do next is to set up a test, in our current FSM project, to build the Horn Girl’s FSM and test it. We are faced with a number of issues, large and small.

  1. Do we want to create about 8 transitions to allow for these four messages and transitions among them, in response to both ?? and receiving the amulet? Or do we want to figure out sub-FSMs?
  2. How would we like to interface with our FSM? We will want to take at least one kind of action, which is to say something at each transition. Sooner or later, we may want to take other actions.

“unindented” offers the ability to define callbacks:

local fsm = require "fsm"

local alert = fsm.create({
  initial = "green",
  events = {
    {name = "warn",  from = "green",  to = "yellow"},
    {name = "panic", from = "yellow", to = "red"   },
    {name = "calm",  from = "red",    to = "yellow"},
    {name = "clear", from = "yellow", to = "green" }
  },
  callbacks = {
    on_panic = function(self, event, from, to, msg) print('panic! ' .. msg)  end,
    on_clear = function(self, event, from, to, msg) print('phew... ' .. msg) end
  }
})

alert.warn()
alert.panic('killer bees')
alert.calm()
alert.clear('they are gone now')

Callbacks, as defined by “unindented”, are passed:

  • self (the fsm)
  • event
  • from
  • to
  • any arguments passed to the event call.

I’m not sure if we want that or not. We’ll try to decide using a new test. But I notice in the above that “unindented” has the events in an inner table named events. That’s better than trusting a mixed table as I do. Let’s change our tests to work that way.

        _:before(function()
            local tab = {
                initial = "s1",
                events = 
                { event="go", from="s1", to="s2" },
                { event="back", from="s2", to="s1" },
            }
            fsm = FSM(tab)
        end)

        _:test("FSM with no initial starts at none", function()
            local tab = {
                events =
                { event="go", from="s1", to="s2" },
                { event="back", from="s2", to="s1" },
            }
            local my_fsm = FSM(tab)
            _:expect(my_fsm:state()).is("none")
        end)

function FSM:performAccepted(event, f)
    for i,trans in ipairs(self.tab.events) do
        if trans.from == self.fsm_state and trans.event == event then
            if f then f(trans) end
            return true
        end
    end
    return false
end

Tests run. Commit: events now stored in inner table named events.

What do we want to have happen in our Horn Girl FSM?

At this point, I think we’re going to have to fold our little FSM into D2, and it has gone smoothly enough that we clearly could have done that right along. I’ll just copy that one tab right in. Could use dependencies, but I know we’re going to be modifying it.

Weirdly, after I paste it in, the tests all run but the game doesn’t start until I touch the screen.

Revert. Problem doesn’t occur. Wha?

Paste it in again.

I notice that now only the FSM tests are running, not all of them. This is weird.

Ah. It’s the same old thing: if the screen display output message is too long, nothing displays. A test has failed here inside the Dung program. Two, actually:

2: FSM can and cannot does she go? -- Actual: false, Expected: true
3: FSM changes state  -- Actual: s1, Expected: s2

Is this happening in the original project? I double check. It is, and somehow I didn’t notice. What a dullard. :)

I think it must relate to my events change.

        _:test("FSM can and cannot", function()
            _:expect(fsm:state()).is("s1")
            _:expect(fsm:can("go"),"does she go?").is(true)
            _:expect(fsm:cannot("back"), "back").is(true)
        end)

Is my fancy new function thing just too fancy?

Ah, I see it. I need another level of {} in my events definition.

        _:before(function()
            local tab = {
                initial = "s1",
                events = {
                    { event="go", from="s1", to="s2" },
                    { event="back", from="s2", to="s1" },
                }
            }
            fsm = FSM(tab)
        end)

OK that fixes it. Now back to the Dung …

Tests run, yay! Now where was I?

Devising some FSM tests for NPC.

NPC has tests, and so has FSM. So we should be able to drive further FSM capability nicely,

Here’s NPC as she stands:

function NPC:init(tile)
    Bus:subscribe(self, self.catPersuasion, "catPersuasion")
    tile:moveObject(self)
    self.sprite = asset.builtin.Planet_Cute.Character_Horn_Girl
    self.messages = {
    "I am your friendly neighborhood Horn Girl!\nAnd do I have a super quest for you!",
    "I am looking for my lost Amulet of Cat Persuasion,\nso that I can persuade the Cat Girl to let me pet her kitty.",
    "If you can find it and bring it to me,\nI shall repay you generously."}
    self.messageNumber = 1
end

function NPC:actionWithPlayer(aPlayer)
    print("NPC saw that")
end

function NPC:catPersuasion()
    Bus:inform("Thanks!")
end

function NPC:draw(tiny, center)
    if tiny then return end
    pushMatrix()
    translate(center.x, center.y)
    sprite(self.sprite,0,30, 66,112)
    popMatrix()
end

function NPC:query()
    local m = self.messages[self.messageNumber]
    self.messageNumber = self.messageNumber + 1
    if self.messageNumber > #self.messages then
        self.messageNumber = 1
    end
    return m
end

So now we’ll want to move some capability into an FSM and then use it.

I think I’ll define the long-form table, so that as much logic as possible is inside the FSM.

function NPC:hornGirlTable()
    local tbl = {
        initial="m1",
        events = {
            {event="query", from="m1", to="m2"},
            {event="query", from="m2", to="m3"},
            {event="query", from="m3", to="m1"},
            {event="received", from="m1", to="ty"},
            {event="received", from="m2", to="ty"},
            {event="received", from="m3", to="ty"},
        }
    }
    return tbl
end

I just went for it here. Note that I didn’t even define the “ty” state, because we can never leave it. I’m not sure if this will fly.

In my NPC test now, what do we wish would happen? Well, ideally, every time we send a query event to our FMS, we’d get back the message to publish. Presently we’ll only get a true back.

        _:test("transitions and messages", function()
            local status,msg
            local npc = NPC(FakeTile())
            local fsm = npc.fsm
            status,msg = fsm:event("query")
            _:expect(status,"first query").is(true)
        end)

I think this should work.

If I’m prepared to break NPC, I can instead change its query method, which is presently this:

function NPC:query()
    local m = self.messages[self.messageNumber]
    self.messageNumber = self.messageNumber + 1
    if self.messageNumber > #self.messages then
        self.messageNumber = 1
    end
    return m
end

Let’s assume we’re going to get the message back somehow from the FSM and return it (the convention is that query returns a message.

function NPC:query()
    local worked = self.fsm:event("query")
    if worked then
        return self.msg or "??? no message"
    else
        return "illegal query???"
    end
end

Now we can call NPC:query and check for the right message.

        _:test("transitions and messages", function()
            local msg
            local npc = NPC(FakeTile())
            local fsm = npc.fsm
            msg = fsm:event("query")
            _:expect(msg).is(npc.messages[1])
        end)

How’s that self.msg going to get set, you’re asking yourself? We have to figure that out. This test should saying no message:

No, wait, that test is wrong. I meant:

        _:test("transitions and messages", function()
            local msg
            local npc = NPC(FakeTile())
            local fsm = npc.fsm
            msg = npc:query()
            _:expect(msg).is(npc.messages[1])
        end)
2: transitions and messages  -- Actual: ??? no message, Expected: I am your friendly neighborhood Horn Girl!
And do I have a super quest for you!

That’s what I was looking for.

The examples we saw allowed us to implement callbacks in our FSM.

Let’s imagine that we want to get a string stored into self.msg via callbacks as we leave the various states.

I’m going to type a callback into my table and see what it might want to look like:

function NPC:hornGirlTable()
    local tbl = {
        initial="m1",
        events = {
            {event="query", from="m1", to="m2"},
            {event="query", from="m2", to="m3"},
            {event="query", from="m3", to="m1"},
            {event="received", from="m1", to="ty"},
            {event="received", from="m2", to="ty"},
            {event="received", from="m3", to="ty"},
        },
        callbacks = {
            on_leave_state = function(fsm,from,to,event)
                self:setMessage(from)
            end,
        },
    }
    return tbl
end

I’m going to call a method setMessage on self, which is the NCP, with the name of the state we’re leaving.

Is this bizarre? A little bit but I think it’ll come together quickly. We put in setMessage in this somewhat hacky way:

function NPC:setMessage(num)
    local tab = {m1=1, m2=2, m3=3, ty=4}
    return self.messages[tab[num]]
end

I think we are building a bug here with the 4. I’ll make a note.

Test still fails, of course, because we’re not calling that function anywhere. Yet.

2: transitions and messages  -- Actual: ??? no message, Expected: I am your friendly neighborhood Horn Girl!
And do I have a super quest for you!

As expected. But now:

function FSM:event(event)
    return self:performAccepted(event, function(trans)
        if self.tab.on_leave_state then
            self.tab.on_leave_state(self, event, trans.from, trans.to)
        end
        self.fsm_state = trans.to
    end)
end

I rather expected this to work. I expected to find an on_leave_state in the table and to execute the function that it contained. That didn’t happen.

Oh. I have to look in callbacks, not directly in self.tab.

function FSM:event(event)
    return self:performAccepted(event, function(trans)
        if self.tab.callbacks.on_leave_state then
            self.tab.callbacks.on_leave_state(self, event, trans.from, trans.to)
        end
        self.fsm_state = trans.to
    end)
end

I convince myself with a quick print that I’m doing the call. And when I do this:

function NPC:setMessage(num)
    print("setMessage", self, num)
    local tab = {m1=1, m2=2, m3=3, ty=4}
    return self.messages[tab[num]]
end

I find out that num is “query”. Better unwind those calling sequences more carefully.

            on_leave_state = function(fsm,from,to,event)
                self:setMessage(from)
            end,

            self.tab.callbacks.on_leave_state(self, event, trans.from, trans.to)

Thur’s yer probum raht thur.

        callbacks = {
            on_leave_state = function(fsm,event,from,to)
                self:setMessage(from)
            end,
        },

This ought to be just fine.

Oh. I forgot to set it anywhere.

        callbacks = {
            on_leave_state = function(fsm,event,from,to)
                self.msg = self:setMessage(from)
            end,
        },

This should work, but I’m not sure I like it. It does work. But who do we want setting the message, the call back function, or the setMessage. I think, given its name, the latter. So:

function NPC:setMessage(num)
    local tab = {m1=1, m2=2, m3=3, ty=4}
    self.msg = self.messages[tab[num]]
end

        callbacks = {
            on_leave_state = function(fsm,event,from,to)
                self:setMessage(from)
            end,
        },

So that works. What happens in game play? I think it’ll work, mostly.

It works, but not for the right reason. The catAmulet message isn’t going to the machine.

Let’s add the thank you message to the messages:

function NPC:init(tile)
    Bus:subscribe(self, self.catPersuasion, "catPersuasion")
    tile:moveObject(self)
    self.sprite = asset.builtin.Planet_Cute.Character_Horn_Girl
    self.messages = {
    "I am your friendly neighborhood Horn Girl!\nAnd do I have a super quest for you!",
    "I am looking for my lost Amulet of Cat Persuasion,\nso that I can persuade the Cat Girl to let me pet her kitty.",
    "If you can find it and bring it to me,\nI shall repay you generously.",
    "Thank you!"}
    self.messageNumber = 1
    self.fsm = FSM(self:hornGirlTable())
end

Oh, bummer. We’re using the on_leave_state callback, and we don’t want to say thank you on leaving m1 or m2 or m3, we really want to say them on entering, at least if the “ty” state is any example.

So we need an initial state where we’ve not spoken, and we need our messages to come out on_enter_state. OK, easily done but we have no tests for this.

I’m going to go ahead: I feel so close!

function NPC:hornGirlTable()
    local tbl = {
        initial="m0",
        events = {
            {event="query", from="m0", to="m1"},
            {event="query", from="m1", to="m2"},
            {event="query", from="m2", to="m3"},
            {event="query", from="m3", to="m1"},
            {event="received", from="m1", to="ty"},
            {event="received", from="m2", to="ty"},
            {event="received", from="m3", to="ty"},
        },
        callbacks = {
            on_enter_state = function(fsm,event,from,to)
                self:setMessage(from)
            end,
        },
    }
    return tbl
end

Now for that new callback:

function FSM:event(event)
    local cb = self.tab.callbacks
    return self:performAccepted(event, function(trans)
        if cb.on_leave_state then
            cb.on_leave_state(self, event, trans.from, trans.to)
        end
        if cb.on_enter_state then
            cb:on_enter_state(self, event, trans.from, trans.to)
        end
        self.fsm_state = trans.to
    end)
end

Now let’s try this again. Oops, no: needs to be this:

function NPC:hornGirlTable()
    local tbl = {
        initial="m0",
        events = {
            {event="query", from="m0", to="m1"},
            {event="query", from="m1", to="m2"},
            {event="query", from="m2", to="m3"},
            {event="query", from="m3", to="m1"},
            {event="received", from="m1", to="ty"},
            {event="received", from="m2", to="ty"},
            {event="received", from="m3", to="ty"},
        },
        callbacks = {
            on_enter_state = function(fsm,event,from,to)
                self:setMessage(to)
            end,
        },
    }
    return tbl
end

We have to call with the to variable on entry.

This does now put out the messages as before, and when I give the item, it says thanks. But wait, I’ve not changed the event, have I? Right.

function NPC:catPersuasion()
    Bus:inform("Thanks!")
end

This needs to be a bit different. First, it should trigger the event, but then we do want to publish the message from here, unlike query, which does it from elsewhere.

function NPC:catPersuasion()
    self.fsm:event("received")
    Bus:inform(self.msg) -- set via callback
end

I’m getting a test error. And I’m running way too fast and spinning my arms to keep from falling down. Need to slow down.

Test failing is:

        _:test("Recognizes cat amulet", function()
            local npc = NPC(FakeTile())
            _:expect(Bus.subscriber, "did not subscribe").is(npc)
            npc:catPersuasion()
        end)

The failure is in the Fake bus:

function FakeEventBus:inform(message)
    _:expect(message).is("Thanks!")
end

So, what’s up with that? Why did we not enter ty state and return the ty message, “Thanks”?

A bit of fiddling finds that we didn’t have a “received” event on “m0”:

function NPC:hornGirlTable()
    local tbl = {
        initial="m0",
        events = {
            {event="query", from="m0", to="m1"},
            {event="query", from="m1", to="m2"},
            {event="query", from="m2", to="m3"},
            {event="query", from="m3", to="m1"},
            {event="received", from="m0", to="ty"},
            {event="received", from="m1", to="ty"},
            {event="received", from="m2", to="ty"},
            {event="received", from="m3", to="ty"},
        },
        callbacks = {
            on_enter_state = function(fsm,event,from,to)
                self:setMessage(to)
            end,
        },
    }
    return tbl
end

Test would run except that I said “Thank you!” in my table and “Thanks!” in the test.

works

So that all works. Commit: NPC now has a simple working FSM to control her messages.

However, as I was testing this, I encountered another message. I’ll see if I can cause it to happen.

Loot:49: expect userdata, got nil
stack traceback:
	[C]: in function 'sprite'
	Loot:49: in method 'draw'
	DungeonContentsCollection:113: in method 'draw'
	GameRunner:326: in method 'drawMapContents'
	GameRunner:288: in method 'draw'
	Main:87: in function 'draw'

Well, I’m not sure what this is but it’s pretty clear we have a Loot with no sprite.

Let’s check Loot:draw:

local LootIcons = {strength="blue_pack", health="red_vial", speed="green_flask",
pathfinder="blue_jar", curePoison="red_vase"}

function Loot:init(tile, kind, min, max)
    self.kind = kind
    self.icon = self:getIcon(self.kind)
    self.min = min
    self.max = max
    self.desc = LootDescriptions[self.kind] or self.kind
    self.message = "I am a valuable "..self.desc
    if tile then tile:moveObject(self) end
end

function Loot:getIcon(kind)
    return LootIcons[kind]
end

OK, if we ever create a Loot whose kind isn’t in that table, we’re going to get this error. First let’s find it:

local RandomLootInfo = {
    {"strength", 4,9},
    {"health", 4,10},
    {"speed", 2,5 },
    {"pathfinder", 0,0},
    {"antidote", 0,0}
}

We expect the name to be curePoison now. Fix this:

local RandomLootInfo = {
    {"strength", 4,9},
    {"health", 4,10},
    {"speed", 2,5 },
    {"pathfinder", 0,0},
    {"curePoison", 0,0}
}

Test a while. Fortunately I find one lying around and we no longer explode. I think that was it. Checking other Loot creations carefully though … I find no others.

Commit: fix problem where naked Poison Antidote Loot caused crash.

We need to beef up the code and/or tests here, but I’m tired and well past lunch time. I’ll make a card and sum up.

Summary

So, this was an interesting morning (and early afternoon). We TDD’d up a simple finite state machine, ultimately including two powerful pluggable functions, on_enter_state and on_leave_state, with callbacks to the fsm’s owner. We used the callback to inform the NPC what message to display.

Now, I think that’s a bit tricksy. We use the callback to set a member variable in the NPC, and use that variable after executing the FSM. That makes the NPC mutable, and we should perhaps either return the message, or publish it from inside the callback, which we do own.

We’ll look at that when my mind is fresh, which it decidedly is not just now.

There were some moments here when I felt I was going too fast, juggling too many balls. The tests were always close enough to work properly.

Speaking of tests, we are pretty well tested within Dung, but the FSM tests themselves do not check the callbacks. Right now, the live version of FSM is in Dung, and if we were ever to export it back to be used as a dependency or in another project, we’d like the tests to be more robust.

Another card.

All that said, other than a couple of moments of WTF, and a feeling of rushing, this has gone well.

Except for that bug I shipped where the Loot could explode. We need that beefed up, and there’s a card for it.

See you next time!


D2.zip