Here’s a TADS3/adv3 module for implementing rule-based finite state machines: stateMachine github repo.
This is an extension to the ruleEngine module discussed in this thread.
For anyone not familiar with the term, a finite state machine is just a way of structuring an algorithm. In the abstract a FSM consists of a set of allowed states and a set of conditions for transitioning between them, with the constraint that the FSM can only have one state at a time.
As an example, let’s look at a simple vending machine. It has two inputs: a coin slot that accepts coins, and a button to dispense something. It has two states: not ready to dispense and ready to dispense. Inserting a coin moves the machine from the not ready state to the ready state, and pushing the button moves the machine from the ready state back to the not ready state. Inserting a coin when the machine is ready will return the coin (we’re not going to keep track of credits here) and pushing the button when the machine is not ready does nothing.
The important thing here is that we’ve got identical inputs in both states, but they’re handled differently.
Without taking care of any of the gameplay stuff (which we’ll get to below) here’s what our implementation looks like:
First, we declare a RuleEngine
instance. This is what will keep track of all the bookkeeping for us:
myRuleEngine: RuleEngine;
Now we declare the state machine itself. The only property we’re
setting is stateID
, which is the ID of the state we want the machine to start in:
vendingMachineState: StateMachine stateID = 'default';
Now we define our first state. This consists of a State
declaration for the state itself, one or more Transition
declarations to define what states we can move to from this state, and Trigger
or Rule
declarations to define what causes the transition. First, we’ll define the “default” state. That’s just the State
class name followed by the single-quoted state ID:
+State 'default';
Our first transition is to the “paid” state, and the trigger is >PUT COIN IN SLOT
:
++Transition toState = 'paid';
+++Trigger srcObject = Coin dstObject = slot action = PutInAction;
Later we’ll come back and add the game logic (for actually moving the coin around, for example), but here we’re just looking at the state transition model.
Next, we define a “no transition” transition for pushing the button when in the “default” state, using the NoTransition
subclass. This doesn’t change the state of the state machine and right now this is a waste of time, but we’ll later hang our custom “pushing the button does nothing” messages off this:
++NoTransition;
+++Trigger dstObject = button action = PushAction;
That’s our default state. For the “paid” state the triggers are identical to the ones for the “default” state, but now the >PUSH BUTTON
returns us to the “default” state and >PUT COIN IN SLOT
does nothing:
+State 'paid';
++Transition toState = 'default';
+++Trigger dstObject = button action = PushAction;
++NoTransition;
+++Trigger srcObject = Coin dstObject = slot action = PutInAction;
Putting that all together, our state machine looks like:
vendingMachineState: StateMachine stateID = 'default';
+State 'default';
++Transition toState = 'paid';
+++Trigger srcObject = Coin dstObject = slot action = PutInAction;
++NoTransition;
+++Trigger dstObject = button action = PushAction;
+State 'paid';
++Transition toState = 'default';
+++Trigger dstObject = button action = PushAction;
++NoTransition;
+++Trigger srcObject = Coin dstObject = slot action = PutInAction;
This handles all the state transition logic, but it doesn’t actually do anything in gameplay terms. To give the state transitions side-effects that change the larger game state we use beforeTransition()
and afterTransition()
methods on our Transition
instances. For example, to handle inserting a coin in the “default” state, we can do something like:
++Transition 'insertCoin'
toState = 'paid'
afterTransition() {
"The coin clatters down into the machine\'s
innards and the button lights up. ";
gDobj.moveInto(nil);
exit;
}
;
Here we define an afterTransition()
method that displays a message, removes the coin from play, and calls exit
. We call exit
because we’re handling everything in the StateMachine
in this example (no Coin.dobjFor(PutIn)
handler to take care of it).
Note that we also gave the Transition
an ID here (“insertCoin”). This isn’t necessary, but the ID will be used in debugging messages if you have debugging turned on.
Doing the same thing for the rest of our transition instances, we end up with something like:
myController: RuleEngine;
vendingMachineState: StateMachine
stateID = 'default'
;
+State 'default';
++Transition 'insertCoin'
toState = 'paid'
afterTransition() {
"The coin clatters down into the machine\'s
innards and the button lights up. ";
gDobj.moveInto(nil);
exit;
}
;
+++Trigger
srcObject = Coin
dstObject = slot
action = PutInAction
;
++NoTransition 'haventPaid'
beforeTransition() {
reportFailure('When {you/he} push{es} the button, it
briefly lights up red. No pebble is dispensed. ');
exit;
}
;
+++Trigger
dstObject = button
action = PushAction
;
+State 'paid';
++Transition 'dispensing'
toState = 'default'
afterTransition() {
local obj;
defaultReport('The vending machine emits a loud
thunking sound as it spits out a pebble. ');
obj = Pebble.createInstance();
obj.moveInto(machine.getOutermostRoom());
exit;
}
;
+++Trigger
dstObject = button
action = PushAction
;
++NoTransition 'coinReturn'
afterTransition() {
"The coin disappears into the slot, and then, after
some brief clattering from the machine, it is
spit back out. ";
gDobj.moveInto(machine.getOutermostRoom());
exit;
}
;
+++Trigger
srcObject = Coin
dstObject = slot
action = PutInAction
;
A complete, compile-able demo using this example is in demo/src/vendingMachine.t
in the repo. The demo includes a vending machine and starts the player with two coins for it. A transcript:
Void
This is a featureless void with a vending machine in one corner.
>push button
When you push the button, it briefly lights up red. No pebble is dispensed.
>put coin in slot
The coin clatters down into the machine's innards and the button lights up.
>put coin in slot
The coin disappears into the slot, and then, after some brief clattering from
the machine, it is spit back out.
>put coin in slot
(first taking the zorkmid)
The coin disappears into the slot, and then, after some brief clattering from
the machine, it is spit back out.
>push button
The vending machine emits a loud thunking sound as it spits out a pebble.
>l
Void
This is a featureless void with a vending machine in one corner.
You see a zorkmid and a pebble here.
NOTE
It’s worth pointing out that for this simple example it would be much more straightfoward to implement the game logic via more traditional dobjFor()
action handlers. This is just intended to illustrate the concepts in a very simple sample problem.
In addition to the methods on Transition
, we can handle things via:
StateMachine.stateTransitionAction()
, which is called whenever there’s a state transition. The method is called with the new state ID as its argumentState.stateStart()
, which is called when the state is made the current stateState.stateEnd()
, which is called on the current state immediately before setting a different state
There’s a simple demo of how these methods work in demo/src/sample.t
in the repo.