A TADS3 module for implementing rule-based finite state machines

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 argument
  • State.stateStart(), which is called when the state is made the current state
  • State.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.

11 Likes

A medium-sized update.

There’s a new transitionAction() method on the Transition class. If defined on a Transition instance, it will replace the current action on the turn the transition’s rules match, halting action resolution (via exit) immediately after it is evaluated.

This is designed to work like most of the transition methods are written in the example in the first message in the thread, but you don’t have to explicitly call exit. The same example with the update looks like:

myController: RuleEngine;

vendingMachineState: StateMachine
        // All we declare on the machine itself is the default state.
        stateID = 'default'
;

+State 'default';
++Transition 'insertCoin'
        toState = 'paid'

        transitionAction() {
                "The coin clatters down into the machine's
                        innards and the button lights up. ";
                gDobj.moveInto(nil);
        }
;
+++Trigger
        srcObject = Coin
        dstObject = slot
        action = PutInAction
;

++NoTransition 'haventPaid'
        transitionAction() {
                reportFailure('When {you/he} push{es} the button, it
                        briefly lights up red.  No pebble is dispensed. ');
        }
;
+++Trigger
        dstObject = button
        action = PushAction
;

+State 'paid';
++Transition 'dispensing'
        toState = 'default'

        transitionAction() {
                local obj;

                defaultReport('The vending machine emits a loud
                        thunking sound as it spits out a pebble. ');

                obj = Pebble.createInstance();
                obj.moveInto(machine.getOutermostRoom());
        }
;
+++Trigger
        dstObject = button
        action = PushAction
;

++NoTransition 'coinReturn'
        transitionAction() {
                "The coin disappears into the slot, and then, after
                        some brief clattering from the machine, it is
                        spit back out. ";
                gDobj.moveInto(machine.getOutermostRoom());
        }
;
+++Trigger
        srcObject = Coin
        dstObject = slot
        action = PutInAction
;

In addition, the beforeTransition() and afterTransition() methods are called immediately before and after the state transition itself, instead of immediately before or after the action that triggered the state transition. This means that they can’t be used to interrupt action resolution, the way they do in the original example.

To help clarify the timing of everything, here’s a little turn lifecycle timeline (including both the general ruleEngine as well as the stateMachine bits):

  • Player action resolution happens early in the turn, and before the action is resolved, objects in scope have their beforeAction() methods called. It is in this window of time where all the rule/trigger evaluation takes place
  • If a Transition’s conditions (as defined by its rules/triggers) are met this turn, it will flag the state machine to change states to whatever the Transition’s toState is defined as (the state transition doesn’t occur immediately). In addition, if the Transition has a transitionAction() method defined, it will be evaluated and then action resolution will be terminated, basically making transitionAction() a replacement action; the normal action handling won’t take place.
  • After action resolution has taken place, everything else in the turn happens. One of the “everything else” things is the polling of daemons. During this window is when the actual state transition takes place.

Expanding the last bit, the specific sequence of events during a state transition is:

First, for the current (before the transition) state and whichever of its Transition instances caused the state change:

  • Transition.beforeTransition() is called
  • State.stateEnd() is called
  • Transition.afterTransition() is called

Then for the new current state:

  • State.stateStart() is called

There’s a demo in the module source (demo/src/beforeAfterTest.t) that’s intended to illustrate the above.

For additional clarity: the nature of the changes are that the beforeTransition() and afterTransition() methods used to be called during action resolution (meaning they could be used to interrupt/replace the action) and now they happen after action resolution (meaning that they can’t change the action results, and they can’t refer to gDobj, gAction, and so on). There’s now a single transitionAction() method that does get called (if it is defined) during action resolution and always interrupts/replaces action resolution.

3 Likes

Man, you do not slow down do you! When IFComp frees me from my review captivity, I’ll check this out. I’m a little daunted, because my lighter-weight custom solution that repurposes ActorStates to general purpose FSM-work is DEEPLY integrated in my WIP and refactoring at this point is unappealing. But this does appear to be a much more capable, less kludgey solution.

2 Likes

Another medium-sized update, this one entirely to the ruleEngine module:

  • Eliminated the non-optimized RuleEngine class. RuleEngine used to be an alias for RuleEngineOptimized and the unoptimized version was RuleEngineBase, but now there’s only a single (optimized) RuleEngine class.
  • Change to the timing of different kinds of Rule checks. Previously Rule and Trigger were both checked during beforeAction(). Now Trigger instances are checked during beforeAction() and Rule instances are checked during afterAction()

The latter change was to handle the corner case where you have a) a rulebook that contains both action triggers and non-action rule checks, and b) the rule check’s state would change based on the outcome of the action.

To illustrate, if you had a rulebook that looked like:

+Trigger
        dstObject = static [ pebble, rock ]
        action = static [ TakeAction, DropAction ]
;
+Rule matchRule(data?) { return(pebble.getCarryingActor() == me); };

That’s a Trigger that matches on >TAKE PEBBLE, >TAKE ROCK, >DROP PEBBLE, or >DROP ROCK and a Rule that matches when the player (me) is holding the pebble.

If the player isn’t holding the pebble and enters the command >TAKE PEBBLE you’d expect the rulebook to match on that turn. But that wasn’t necessarily true. The trigger would match. That would cause the rulebook to be evaluated. And then, because the trigger happens before the action is completed the rule would fail (because the pebble wouldn’t be in the player’s inventory yet). And that would cause the rulebook to not be matched for the turn.

Anyway, now Triggers are all evaluated before action resolution (and therefore possibly interrupting the action) and Rules are all evaluated after action resolution (and therefore can’t interrupt the current turn’s action, but do reflect the outcome of that action). Which I think is what makes the most intuitive sense.

3 Likes