Finite State Machine base class

While all that is needed for a Finite State Machine is a simple integer variable, it is often clearer in the code to refer to the states by name. This base class (it is intended to be used as a base for other specific state machine classes) allows an optional sequence of state names to be supplied.

To allow for new states to be added in future releases only the name of the renpy.store attribute is kept by the state machine, not the sequence itself. This ensures earlier versions of the state name sequence are not persisted in the save file.

Constructor

The constructor is used to initialise the base Finite State Machine and create two members:

classes/fsm.rpy

#
# Finite State Machine.
#
init python:
    class Fsm(object):
        """
        Basic state machine.

        State is held as a non-negative integer (0..n-1).
        An instance can optionally be supplied with a sequence of names for each state.
        If a sequence is provided the state is limited to only those states.

        Declaration:
        define  stateNames = ('init', 'first', 'second',)
        default state = Fsm('stateNames')
        """

        # ---------------------------------------------------------------------
        # Constructor
        # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

        def __init__(self, stateNames=None):
            """
            Construct a basic finite state machine with initial state 0.

            Optionally provide a renpy.store name of sequence of state names.
            This can be a list or a tuple of strings.

            :param stateNames: Optional  store name of a sequence of state names
            """
            self._stateNumM = 0
            self._stateNamesM = stateNames

Accessors

A number of accessor methods are provided to access and change the state by number or by name.

Reset

The reset() method simply sets the state machine back to its initial state.

classes/fsm.rpy (cont.)

        # ---------------------------------------------------------------------
        # Accessors
        # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

        def reset(self):
            """
            Reset to state zero.
            """
            self.state = 0

State

The state is defined as a property so it can be accessed as though it were a member, but sanity checking can be performed when it is assigned to.

classes/fsm.rpy (cont.)

        @property
        def state(self):
            """
            The state number which is a non-negative integer.
            """
            return self._stateNumM

        @state.setter
        def state(self, value):
            """
            Set the state number.

            The state number cannot be None or negative.
            If a sequence of state names was provided the value must be a valid index.

            :param value: The new state number
            """
            if value is None:
                raise ValueError("State cannot be set to None.")
            if not isinstance(value, int):
                raise ValueError("State cannot be set to a non-integer value ({})".format(value))
            if value < 0:
                raise ValueError("State cannot be set to a negative value ({}).".format(value))
            if self._stateNamesM is not None:
                names = self._lookupNames()
                if value >= len(names):
                    raise ValueError("State cannot be set outside known states 0..{} ({}).".format(len(names)-1, value))
            self._stateNumM = value

Optionally for debugging state transitions the following could be added to the end of state.setter assuming you have a dbg character and default debugFsm somewhere. Alternatively do something similar with printf to the console.


            if debugFsm:
                if names is not None:
                    dbg("{}.state={}:{}".format(self.__class__.__name__, value, names[value]))
                else:
                    dbg("{}.state={}".format(self.__class__.__name__, value))

State name

The stateName is also defined as a property. The getter retrieves the state name from the sequence of states (if provided at construction). The setter checks for a valid state name.

classes/fsm.rpy (cont.)

        @property
        def stateName(self):
            """
            The state name corresponding to the current state.
            """
            names = self._lookupNames()
            if names is None:
                return str(self._stateNumM)
            return names[self._stateNumM]

        @stateName.setter
        def stateName(self, value):
            """
            Set the state by name.

            Only valid if a sequence of state names was provided.

            :param value: The new state name
            """
            names = self._lookupNames()
            if names is None:
                raise ValueError("State names cannot be used without first providing a list of states ({}).".format(value))
            if value not in names:
                raise ValueError("State '{}' is not a valid state name.".format(value))
            self.state = names.index(value)

State number

It may occasionally be useful to retrieve the state number corresponding to a state name.

classes/fsm.rpy (cont.)

        def stateNumber(self, value):
            """
            Get the state number for a given state name.

            :param value:   A state name
            :return:        The state number
            """
            names = self._lookupNames()
            if names is None:
                raise ValueError("State names cannot be used without first providing a list of states ({}).".format(value))
            if value not in names:
                raise ValueError("State '{}' is not a valid state name.".format(value))
            return names.index(value)

Special methods

Two of the special (dunder) methods are given implementations. Others could be added if needed. I chose not to add the various comparison operations as comparing the state of two finite state machines does not seem to be useful.

bool()

Boolean conversion is handled by the special method __bool__ in Python 3. In Python 2 this was __non_zero__. For the state machine it can be useful if the reset state should convert to False and all other states read as True.

classes/fsm.rpy (cont.)

        # ---------------------------------------------------------------------
        # Special methods
        # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

        def __bool__(self):
            """
            Determine if the state has advanced beyond the reset state 0.

            :return: True if not in the initial state
            """
            return self._stateNumM != 0

        __non_zero__ = __bool__     # Python 2 compatibility

str()

String conversion is handled by the special method __str__. If a sequence of state names is available use the state number followed by a : and the state name. Otherwise just use the number.

classes/fsm.rpy (cont.)

        def __str__(self):
            """
            Generate a printable string describing the state.

            :return: A printable state name or number
            """
            names = self._lookupNames()
            if names is None:
                return str(self._stateNumM)
            return "{}:{}".format(self._stateNumM, names[self._stateNumM])

Private methods

The only "private" method needed is the one to retrieve the sequence of state names. This is designed to fail gracefully by returning None if no store name for the sequence was provided at construction or if the attribute name is not valid.

classes/fsm.rpy (cont.)

        # ---------------------------------------------------------------------
        # Private methods
        # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

        def _lookupNames(self):
            """
            Retrieve the sequence of state names from the renpy store.

            :return: The state name sequence, or None if not provided or available
            """
            if self._stateNamesM is None:
                return None
            return getattr(renpy.store, self._stateNamesM, None)

Example use

This is really intended to be a base for a derived class that adds logic for transitions, but it can be used as it stands. First, create a state machine with three named states and print out its initial state number and name:


define  stateNames = ('init', 'first', 'second')
default fsm = Fsm('stateNames')

label exFsm:
    "Initial state: [fsm.state] [fsm.stateName]"

Initial state: 0 init

The state can be changed by assigning to state:


    $ fsm.state = 1
    "New state: [fsm.state] [fsm.stateName]"

New state: 1 first

It can also be changed with operators like +=:


    $ fsm.state += 1
    "New state: [fsm.state] [fsm.stateName]"

New state: 2 second

And reset if needed.


    $ fsm.reset()
    "Reset state: [fsm.state] [fsm.stateName]"

Reset state: 0 init

So far it behaves a lot like a simple integer, but the state can also be changed by assigning one of the defined state names to stateName:


    $ fsm.stateName = 'second'
    "New state: [fsm.state] [fsm.stateName]"

New state: 2 second

These operations would all cause exceptions though as the code is checking to make sure only valid states are allowed:


    # These would all raise exceptions:
    $ fsm.stateName = 'third'
    $ fsm.state = 3
    $ fsm.state = 'init'
    $ fsm.state = None
    $ fsm.state = 0.1