State Table
Hello, loves!
Weird hours again, but very interesting spike. Have a look! Tout le monde déteste l’IA.
So last night, instead of waking up early, I couldn’t get to sleep, which rarely happens. Nothing really bugging me, just couldn’t drop off, so I got up at 0050 or something and came to the computer. I set out to make a state table for the QuestGiverDenizen.
I thought the table should be a dictionary of dictionaries, which is weird but seemed at 0100 to make sense. At first I got nowhere and so I drew a little state diagram om a card, and then sketched a table.

With that down on paper, I went back to the code. First, I extracted the action steps that the Denizen takes on each event. This is still the existing code, just with the actions extracted:
def interact(self, interactor):
self.state = self.state(interactor)
return False
def seeking(self, interactor):
if interactor.has(self.knowledge.quest_item):
self.giving_action(interactor)
return self.satisfied
else:
self.seeking_action(interactor)
return self.seeking
def satisfied(self, interactor):
self.satisfied_action(interactor)
def seeking_action(self, interactor):
interactor.announce(next(self.knowledge.seeking_sayings))
def giving_action(self, interactor):
if self.knowledge.gift:
for saying in self.knowledge.giving_sayings:
interactor.announce(saying)
interactor.receive_content(self.knowledge.gift)
return self.satisfied
def satisfied_action(self, interactor):
interactor.announce(next(self.knowledge.satisfied_sayings))
Then I added two new instance variables, a new state variable cleverly names state2, containing a string, ‘seeking’ or ‘satisfied’, and a new table, called trans because I was too lazy to type transitions a lot:
class QuestGiverDenizen:
def __init__(self, *, name,
knowledge,):
self.name = name
self.knowledge = knowledge
self.state = self.seeking
self.state2='seeking'
self.trans=dict(
seeking=dict(
has_item=dict(to='satisfied', hook=self.giving_action),
no_item=dict(to='seeking', hook=self.seeking_action),
),
satisfied=dict(
has_item=dict(to='satisfied', hook=self.satisfied_action),
no_item=dict(to='satisfied', hook=self.satisfied_action),
),
)
Then, still without a test if I recall correctly, I wrote a new event method, intended to be called with the parameter event one of two strings, ‘has_item’ or ‘no_item’, depending on whether Dot has or does not have the quest item.
def event(self, event, interactor):
transition = self.trans[self.state2][event]
transition['hook'](interactor)
self.state2 = transition['to']
Let’s walk through that. We look into the trans table for the dictionary corresponding to our current state, and in that, we look up the dictionary corresponding to the event. Then we fetch the item named ‘hook’ from that dictionary, and execute it, since it is a method name, passing the interactor. Then finally, we set the state to the value of the ‘to’ key in the transition.
So if we come in in state ‘seeking’ with the ‘has_item’ event, we select the ‘seeking’ dictionary’s ‘has_item’ value, namely:
dict(to='satisfied', hook=self.giving_action)
So we execute the method giving_action, passing the interactor parameter, and then we set the state to ‘satisfied’.
As we can see above, the giving_action says the blurb about thanks, and gives the gift, and now we are in state satisfied. Both events in satisfied call satisfied_action, which says one of the satisfied sayings, and we remain in ‘satisfied’ forever.
At that point I wrote a test:
def test_table(self):
interactor = FakeInteractor()
seek_text = NameSayer.cycle('Buzz', ['want flower'])
gift_text = NameSayer.once('Buzz', ['here comb'])
sat_text = NameSayer.cycle('Buzz', ['bzzz'])
knowledge = SimpleNamespace(
gift='delicious honeycomb',
seeking_sayings=seek_text,
giving_sayings=gift_text,
satisfied_sayings=sat_text,
quest_item='a flower'
)
bee = QuestGiverDenizen(name='Buzz', knowledge=knowledge)
bee.event('has_item', interactor)
assert bee.state2 == 'satisfied'
assert 'delicious honeycomb' in interactor.inventory
print(interactor.kwargs)
assert False
I was ready for bed by the time I did this, so rather than work out how to check the message, I just printed the message table and asserted False, to be found this morning. We can do better now that I’m awake:
assert bee.state2 == 'satisfied'
assert 'delicious honeycomb' in interactor.inventory
assert interactor.kwargs['message'] == 'Buzz: "here comb"'
And your point is?
And my point includes but is not limited to:
- We can simplify QuestGiverDenizen quite a bit, which we’ll do next.
- When that’s done, if the table were a parameter instead of built in, we could have more complex state machines and they should work just fine.
I grant that there was a bit of hand-waving in that last one, but clearly the possibility is there. We’ll explore that possibility if and when we ever need a more complex denizen.
Let’s see about converting the QuestGiverDenizen to use only the table. I think we just need to change its interact to call event.
def interact(self, interactor):
event = 'has_item' \
if interactor.has(self.knowledge.quest_item) \
else 'no_item'
self.event(event, interactor)
return False
I was quite hopeful that that would just work. But two tests fail. I can’t resist running the game, and Buzz works perfectly. SO that’s good. Now those tests. The first one is checking bee.state, which we no longer use:
def test_receives(self):
interactor = FakeInteractor()
initial_sayings = NameSayer.cycle('Buzz', ['Hmm'])
gift_sayings = NameSayer.once('Buzz', ['may need'])
knowledge = SimpleNamespace(quest_item='a flower', gift='anything', seeking_sayings=initial_sayings, giving_sayings=gift_sayings)
bee = QuestGiverDenizen(name='Buzz', knowledge=knowledge)
bee.interact(interactor)
assert 'Hmm' in interactor.kwargs['message']
interactor.inventory.append('a flower')
bee.interact(interactor)
assert 'may need' in interactor.kwargs['message']
assert bee.state == bee.satisfied
Can change that to check state2:
assert bee.state2 == 'satisfied'
It’s green. The second test has the same issue. Fixed. Green. Let’s rip out all the unnecessary stuff from QGDenizen, namely these methods:
#deleted
def seeking(self, interactor):
if interactor.has(self.knowledge.quest_item):
self.giving_action(interactor)
return self.satisfied
else:
self.seeking_action(interactor)
return self.seeking
def satisfied(self, interactor):
self.satisfied_action(interactor)
return self.satisfied
Unfortunately that breaks a bunch of tests. Let’s see why. I’m confident in the code, but I’d like to preserve as many tests as I can.
Well, a lot of the trouble in in the init, which we need to trim anyway. Remove this line from init:
self.state = self.seeking
Green. Let’s commit this: QuestGiverDenizen now represents state machine as a “table” consisting of nested dictionaries.
Now some cleanup. Here’s the init:
class QuestGiverDenizen:
def __init__(self, *, name,
knowledge,):
self.name = name
self.knowledge = knowledge
self.state2='seeking'
self.trans=dict(
seeking=dict(
has_item=dict(to='satisfied', hook=self.giving_action),
no_item=dict(to='seeking', hook=self.seeking_action),
),
satisfied=dict(
has_item=dict(to='satisfied', hook=self.satisfied_action),
no_item=dict(to='satisfied', hook=self.satisfied_action),
),
)
Rename state2 to state and trans to transitions. One more thing. Let’s take out the condition here:
def giving_action(self, interactor):
if self.knowledge.gift:
for saying in self.knowledge.giving_sayings:
interactor.announce(saying)
interactor.receive_content(self.knowledge.gift)
We want always to say the stuff and currently we always have a gift. If we ever just want to give advice, we can invent a null gift that the interactor or dungeon or Dot ignores. Done. We’ll see the whole class in a moment, I’ll spare you for now. Now this bugs me a bit:
def interact(self, interactor):
event = 'has_item' \
if interactor.has(self.knowledge.quest_item) \
else 'no_item'
self.event(event, interactor)
return False
I think there are two issues here, and we’ll just deal with one of them. The smaller issue is that that conditional makes the method hard to read, so Extract Method and then Inline Variable to get:
def interact(self, interactor):
event = self._which_event(interactor)
self.event(event, interactor)
return False
def _which_event(self, interactor):
return 'has_item' \
if interactor.has(self.knowledge.quest_item) \
else 'no_item'
We might inline the temp in interact. Let’s see how it looks: I expect not to like it but I’m not sure.
def interact(self, interactor):
self.event(self._which_event(interactor), interactor)
return False
I think we’ll leave it without the inline. Undo. Green. Commit: tidying.
Here’s the whole class now:
class QuestGiverDenizen:
def __init__(self, *, name,
knowledge,):
self.name = name
self.knowledge = knowledge
self.state= 'seeking'
self.transitions=dict(
seeking=dict(
has_item=dict(to='satisfied', hook=self.giving_action),
no_item=dict(to='seeking', hook=self.seeking_action),
),
satisfied=dict(
has_item=dict(to='satisfied', hook=self.satisfied_action),
no_item=dict(to='satisfied', hook=self.satisfied_action),
),
)
def interact(self, interactor):
event = self._which_event(interactor)
self.event(event, interactor)
return False
def _which_event(self, interactor):
return 'has_item' \
if interactor.has(self.knowledge.quest_item) \
else 'no_item'
def event(self, event, interactor):
transition = self.transitions[self.state][event]
transition['hook'](interactor)
self.state = transition['to']
def seeking_action(self, interactor):
interactor.announce(next(self.knowledge.seeking_sayings))
def giving_action(self, interactor):
for saying in self.knowledge.giving_sayings:
interactor.announce(saying)
interactor.receive_content(self.knowledge.gift)
def satisfied_action(self, interactor):
interactor.announce(next(self.knowledge.satisfied_sayings))
Reflection
The basic statistics are not in our favor with this new version: it is 46 lines long and the final version from yesterday was only 28. The new scheme is 18 lines longer, which is a lot given that we started with only 28.
However, if we were to call event directly from the Content item that holds the bee, we could remove 11 of those lines, the ones that convert interact to event. If and when we are to generalize this class to accept different state tables, we’ll certainly want to do that.
And, while my motivation was mostly to get tired enough to sleep, and partly to see how to represent the transitions as a data structure, we have kind of accidentally wound up with a more general purpose kind of Denizen, a StateDrivenDenizen. If, in the fullness of time, we were to populate it with other useful hook methods, we could do a lot with it.
I would not generally recommend setting out to generalize an object in this fashion: we have no need of it, no feature that requires it. Still, if someone built it at oh-dark-thirty, I think I’d recommend that we keep it, as it is well-tested and easy enough to understand.
In any case, today I plan to keep it: it is either less arcane or at least differently arcane, and I suspect that we’ll find it useful in the future. We might even try to invent something to make use of the generality, because we’re here to practice and to learn and there will surely be both involved in using it.
The dictionary of dictionaries structure is questionable. In particular, I’m thinking about the inner bits, the ones that look like this:
dict(to='satisfied', hook=self.giving_action)
I think those might be better as a tiny object addressable by thing.to and thing.hook might clarify the code a bit. Perhaps we’ll try that later.
Summary
I’m happy with the result, as I had been trying in the back of my mind to see how to make the state machine table-driven and it finally came clear enough, after I read a number of not quite satisfactory examples and stackoverflow answers.
As a thing to do when production is on the line, I might hold off until there was a need for more generality. As a learning thing for us to consider here, I’m fine with it.
See you next time!