YASM - Yet Another State Machine

Pronounced "yaz-um."

Install?

$ gem install yasm

Why?

In a state machine, there are states, contexts, and actions. Actions have side-effects, conditional logic, etc. States have various allowable actions. Contexts can support various states. All beg to be defined in classes. The other ruby state machines out there are great. But they all have hashitis. Classes and mixins are the cure.

How?

Let's create a state machine for a vending machine. What does a vending machine do? It lets you input money and make a selection. When you make a selection, it vends the selection. Let's start off with a really simple model of this:

class VendingMachine
  include Yasm::Context

  start :waiting
end

class Waiting; include Yasm::State; end

class Vending; include Yasm::State; end

So far, we've created a context (a thing that has state), given it a start state, and then defined a couple of states (Waiting, Vending).

So, how do we use this vending machine? We'll need to create some actions first:

class InputMoney
  include Yasm::Action
end

class MakeSelection
  include Yasm::Action

  triggers :vending
end

class RetrieveSelection
  include Yasm::Action

  triggers :waiting
end

And now we can run a simulation:

vending_machine = VendingMachine.new

vending_machine.state.value 
  #==> Waiting

vending_machine.do! InputMoney

vending_machine.state.value 
  #==> Waiting

vending_machine.do! MakeSelection

vending_machine.state.value 
  #==> Vending

vending_machine.do! RetrieveSelection

vending_machine.state.value 
  #==> Waiting

There's some problems, though. Our simple state machine is a little too simple; someone could make a selection without inputing any money. We need a way to limit the actions that can be applied to our vending machine based on it's current state. How do we do that? Let's redefine our states, using the actions macro:

class Waiting
  include Yasm::State

  actions :input_money, :make_selection
end

class Vending
  include Yasm::State

  actions :retrieve_selection
end

Now, when the vending machine is in the Waiting state, the only actions we can apply to it are InputMoney and MakeSelection. If we try to apply invalid actions to the context, Yasm will raise an exception.

vending_machine.state.value 
  #==> Waiting

vending_machine.do! RetrieveSelection
  #==> InvalidActionException: We're sorry, but the action `RetrieveSelection` 
       is not possible given the current state `Waiting`.

vending_machine.do! InputMoney

vending_machine.state.value 
  #==> Waiting

Side Effects

How can we take our simulation farther? A real vending machine would verify that when you make a selection, you actually have input enough money to pay for that selection. How can we model this?

For starters, we'll need to add a property to our VendingMachine that lets us keep track of how much money was input. We'll also need to initialize our InputMoney actions with an amount.

class VendingMachine
  include Yasm::Context
  start :waiting

  attr_accessor :amount_input

  def initialize
    @amount_input = 0
  end
end 

class InputMoney
  include Yasm::Action

  def initialize(amount_input)
    @amount_input = amount_input
  end

  def execute
    context.amount_input += @amount_input
  end
end

Notice I defined the execute method on the action. This is the method that gets run whenever an action gets applied to a state container (e.g., vending_machine.do! InputMoney). This is where you create side effects.

Now we can try out adding money into our vending machine:

vending_machine.amount_input
  # ==> 0

vending_machine.do! InputMoney.new(10)

vending_machine.amount_input
  # ==> 10

As for verifying that we have input enough money to pay for the selection we've chosen, we'll need to create an item, then add that to our MakeSelection class:

class SnickersBar
  def self.price; 30; end
end

class MakeSelection
  include Yasm::Action

  def initialize(selection)
    @selection = selection
  end

  def execute
    if context.amount_input >= @selection.price
      trigger Vending
    else
      raise "We're sorry, but you have not input enough money for a #{@selection}"
    end
  end
end

Notice that we called the trigger method inside the execute method instead of calling the triggers macro on the action. This way, we can conditionally move to the next logical state only when our conditions have been met (in this case, that we've input enough money to pay for our selection).

v = VendingMachine.new

v.amount_input 
  #==> 0

v.do! MakeSelection.new(SnickersBar)
  #==> RuntimeError: We're sorry, but you have not input enough money for a SnickersBar

v.do! InputMoney.new(10)

v.do! MakeSelection.new(SnickersBar)
  #==> RuntimeError: We're sorry, but you have not input enough money for a SnickersBar

v.do! InputMoney.new(20)

v.do! MakeSelection.new(SnickersBar)

v.state.value 
  #==> Vending

v.do! RetrieveSelection

v.state.value
  #==> Waiting

End states

Sometimes, a state is final. Like, what if, out of frustration, you threw the vending machine off the top of a 10 story building? It's probably not going to work again after that. You can use the final! macro on a state to denote that this is the end.

class TossOffBuilding
  include Yasm::Action

  triggers :obliterated
end

class Obliterated
  include Yasm::State

  final!
end

vending_machine = VendingMachine.new

vending_machine.do! TossOffBuilding

vending_machine.do! MakeSelection.new(SnickersBar)
#==> Yasm::FinalStateException: We're sorry, but the current state `Obliterated` is final. It does not accept any actions. 

State Timers

When a vending machine vends an item, it takes about 10 seconds for the item to work it's way off the rack and fall to the bottom. We can simulate this by placing a minimum constraint on the Vending state.

class Vending
  include Yasm::State

  minimum 10.seconds
end

Now, when we go into the vending state, we won't be able to retrieve our selection until 10 seconds have passed.

vending_machine.do! MakeSelection.new(SnickersBar)

vending_machine.state.value
  #==> Vending

vending_machine.do! RetrieveSelection
  #==> Yasm::TimeLimitNotYetReached: We're sorry, but the time limit on the state `Vending` has not yet been reached. 

sleep 10

vending_machine.do! RetrieveSelection

vending_machine.state.value
  #==> Waiting

You can also create maximum time limits. For example, suppose we want our vending machine to self destruct, out of frustration, if it goes an entire minute without any action.

class Waiting
  include Yasm::State

  maximum 1.minute, :action => :self_destruct
end

class SelfDestruct
  include Yasm::Action

  triggers :obliterated

  def execute
    puts "KABOOM!"
  end
end

Now, if we create a vending machine, then wait at least a minute, next time we try to do something to it, it will execute the SelfDestruct action.

v = VendingMachine.new

sleep 60

v.do! InputMoney.new(10)
  #==> "KABOOM!"
  #==> Yasm::FinalStateException: We're sorry, but the current state `Obliterated` is final. It does not accept any actions. 

PUBLIC DOMAIN

This software is committed to the public domain. No license. No copyright. DO ANYTHING!