Permalink
Browse files

adding array support in the event's "from" value and passing

in the state change information to callback methods.
  • Loading branch information...
1 parent 2ffbf32 commit 844542bfac93fa31affdc6772ef3f78a7f6a4ce0 @stephenb committed May 19, 2011
Showing with 266 additions and 37 deletions.
  1. +0 −5 README
  2. +150 −0 README.md
  3. +41 −15 lib/state_machine.js
  4. +6 −2 package.json
  5. +21 −12 src/state_machine.coffee
  6. +48 −3 test/test_state_machine.coffee
View
5 README
@@ -1,5 +0,0 @@
-Description:
-A simple state machine written in CoffeeScript.
-
-Usage:
-TODO
View
150 README.md
@@ -0,0 +1,150 @@
+Description:
+------------
+
+A simple state machine written in CoffeeScript.
+
+Sample Usage:
+------
+
+A "StateMachine" class is provided that can be used as the basis of your state machine implementation.
+The object passed in to the constructor has an expected format that will define the state machine.
+The sample stuff below will use a chess game as a basic example.
+
+Step one will always be to require the state machine:
+
+ {StateMachine} = require 'state_machine'
+
+The StateMachine class' constructor takes in an object that defines the entire state machine.
+Here's what it looks like:
+
+ states:
+ stateName:
+ active: true/false (optional, the 1st state defaults to true)
+ onEnter: enterMethod (called when successfully entering the state)
+ onExit: exitMethod (called when successfully exiting the state)
+ guard: guardMethod (stops the change to this state if returns false)
+ stateName2: etc...
+ events:
+ eventName:
+ from: fromState (should be a defined state, or "any")
+ to: toState (should be a defined state)
+ eventName2: etc...
+ onStateChange: changeMethod (called on any state change)
+
+If you don't need anything fancy on the states, then you can use a basic Array setup:
+
+ game = new StateMachine states: ['whiteToMove', 'blackToMove']
+
+ game.availableStates()
+ # outputs: [ 'whiteToMove', 'blackToMove' ]
+ game.currentState()
+ # outputs: 'whiteToMove'
+
+But, you should really define some *events* that will trigger state changes. Each
+defined event gives you a method you can call to trigger the state change.
+
+ class ChessGame extends StateMachine
+ switchSides: ->
+ # ...
+ console.log "switchSides called."
+
+ game = new ChessGame
+ states:
+ whiteToMove:
+ onEnter: -> this.switchSides()
+ blackToMove:
+ onEnter: -> this.switchSides()
+ events:
+ whiteMoved: {from:'whiteToMove', to:'blackToMove'}
+ blackMoved: {from:'blackToMove', to:'whiteToMove'}
+
+ game.whiteMoved()
+ # outputs: switchSides called.
+
+You can also pass the states definition to the defineStateMachine method. So, a more custom
+and comprehensive implementation may look like:
+
+ class ChessGame extends StateMachine
+ constructor: (@board, @pieces) ->
+ @defineStateMachine
+ states:
+ whiteToMove:
+ # If black was in check, sides can't switch unless they're now not in check
+ guard: (args) -> not (args.from is 'blackInCheck' and this.blackKingInCheck())
+ onEnter: -> this.deliverMessage('white', 'Your move.')
+ blackToMove:
+ guard: (args) -> not (args.from is 'whiteInCheck' and this.whiteKingInCheck())
+ onEnter: -> this.deliverMessage('black', 'Your move.')
+ whiteInCheck:
+ onEnter: -> this.deliverMessage('white', 'Check!')
+ onExit: -> this.deliverMessage('white', 'Check escaped.')
+ blackInCheck:
+ onEnter: -> this.deliverMessage('black', 'Check!')
+ onExit: -> this.deliverMessage('black', 'Check escaped.')
+ whiteCheckmated:
+ onEnter: ->
+ this.deliverMessage('white', 'Checkmate, you lose :-(')
+ this.deliverMessage('black', 'Checkmate, you win!')
+ blackCheckmated:
+ onEnter: ->
+ this.deliverMessage('black', 'Checkmate, you lose :-(')
+ this.deliverMessage('white', 'Checkmate, you win!')
+ events:
+ whiteMoved: { from: 'whiteToMove', to: 'blackToMove' }
+ whiteChecked: { from: ['blackToMove', 'blackInCheck'], to: 'whiteInCheck' }
+ whiteCheckMated: { from: ['blackToMove', 'blackInCheck'], to: 'whiteCheckmated' }
+ blackMoved: { from: 'blackToMove', to: 'whiteToMove' }
+ blackChecked: { from: ['whiteToMove', 'whiteInCheck'], to: 'blackInCheck' }
+ blackCheckMated: { from: ['whitetoMove', 'whiteInCheck'], to: 'blackCheckmated' }
+ onStateChange: (args) -> this.logActivity(args.from, args.to, args.event)
+
+ blackKingInCheck: ->
+ # ...
+
+ whiteKingInCheck: ->
+ # ...
+
+ deliverMessage: (playerColor, message) ->
+ console.log "[Message to #{playerColor}] #{message}"
+
+ logActivity: (from, to, event) ->
+ console.log "Activity: from => #{from}, to => #{to}, event => #{event}"
+
+ ##################################
+
+ game = new ChessGame
+
+ game.whiteMoved()
+ # outputs:
+ # [Message to black] Your move.
+ # Activity: from => whiteToMove, to => blackToMove, event => whiteMoved
+
+ game.blackMoved()
+ # outputs:
+ # [Message to white] Your move.
+ # Activity: from => blackToMove, to => whiteToMove, event => blackMoved
+
+ game.blackChecked()
+ # outputs:
+ # [Message to black] Check!
+ # Activity: from => whiteToMove, to => blackInCheck, event => blackChecked
+
+ game.whiteCheckMated()
+ # outputs:
+ # [Message to black] Check escaped.
+ # [Message to white] Checkmate, you lose :-(
+ # [Message to black] Checkmate, you win!
+ # Activity: from => blackInCheck, to => whiteCheckmated, event => whiteCheckMated
+
+ try
+ game.blackMoved()
+ catch error
+ console.log error
+ # outputs:
+ # Cannot change from state 'blackToMove'; it is not the active state!
+
+
+Note that each callback method (onEnter, onExit, guard, and onStateChange) gets passed an args object that
+has a "from", "to", and "event" key, providing the previous state, new state, and the
+event that triggered the state change.
+
View
56 lib/state_machine.js
@@ -4,8 +4,18 @@
root = typeof exports !== "undefined" && exports !== null ? exports : window;
root.StateMachine = StateMachine = (function() {
function StateMachine(stateMachine) {
- var activeStates, event, eventDef, state, stateDef, states, _fn, _i, _j, _len, _len2, _ref, _ref2;
- this.stateMachine = stateMachine != null ? stateMachine : {};
+ this.stateMachine = stateMachine != null ? stateMachine : {
+ states: {},
+ events: {}
+ };
+ this.defineStateMachine(this.stateMachine);
+ }
+ StateMachine.prototype.defineStateMachine = function(stateMachine) {
+ var activeStates, event, eventDef, state, stateDef, states, _i, _j, _len, _len2, _ref, _ref2, _results;
+ this.stateMachine = stateMachine != null ? stateMachine : {
+ states: {},
+ events: {}
+ };
if (this.stateMachine.states.constructor.toString().indexOf('Array') !== -1) {
states = this.stateMachine.states;
this.stateMachine.states = {};
@@ -47,16 +57,17 @@
}
}
_ref2 = this.stateMachine.events;
- _fn = __bind(function(event, eventDef) {
- return this[event] = function() {
- return this.changeState(eventDef.from, eventDef.to);
- };
- }, this);
+ _results = [];
for (event in _ref2) {
eventDef = _ref2[event];
- _fn(event, eventDef);
+ _results.push(__bind(function(event, eventDef) {
+ return this[event] = function() {
+ return this.changeState(eventDef.from, eventDef.to, event);
+ };
+ }, this)(event, eventDef));
}
- }
+ return _results;
+ };
StateMachine.prototype.currentState = function() {
var state, stateDef;
return ((function() {
@@ -93,8 +104,18 @@
}
return _results;
};
- StateMachine.prototype.changeState = function(from, to) {
- var enterMethod, exitMethod, fromStateDef, guardMethod, toStateDef;
+ StateMachine.prototype.changeState = function(from, to, event) {
+ var args, enterMethod, exitMethod, fromStateDef, guardMethod, toStateDef;
+ if (event == null) {
+ event = null;
+ }
+ if (from.constructor.toString().indexOf('Array') !== -1) {
+ if (from.indexOf(this.currentState()) !== -1) {
+ from = this.currentState();
+ } else {
+ throw "Cannot change from states " + (from.join(' or ')) + "; none are the active state!";
+ }
+ }
fromStateDef = this.stateMachine.states[from];
toStateDef = this.stateMachine.states[to];
if (toStateDef === void 0) {
@@ -113,17 +134,22 @@
fromStateDef = this.stateMachine.states[this.currentState()];
}
exitMethod = fromStateDef.onExit;
- if (guardMethod !== void 0 && guardMethod.call() === false) {
+ args = {
+ from: from,
+ to: to,
+ event: event
+ };
+ if (guardMethod !== void 0 && guardMethod.call(this, args) === false) {
return false;
}
if (exitMethod !== void 0) {
- exitMethod.call();
+ exitMethod.call(this, args);
}
if (enterMethod !== void 0) {
- enterMethod.call();
+ enterMethod.call(this, args);
}
if (this.stateMachine.onStateChange !== void 0) {
- this.stateMachine.onStateChange.call();
+ this.stateMachine.onStateChange.call(this, args);
}
fromStateDef.active = false;
return toStateDef.active = true;
View
8 package.json
@@ -7,12 +7,16 @@
"type": "git",
"url": "git://github.com/stephenb/state_machine.git"
},
+ "main": "./lib/state_machine",
"scripts": {
"test": "cake test"
},
"engines": {
- "node": "~v0.4.7"
+ "node": ">=v0.4.7"
},
"dependencies": {},
- "devDependencies": {}
+ "devDependencies": {
+ "vows": ">=v0.5.8",
+ "coffee-script": ">=v1.1.1"
+ }
}
View
33 src/state_machine.coffee
@@ -9,20 +9,22 @@ root = exports ? window
# guard: guardMethod (stops the change to this state if returns false)
# events:
# eventName:
-# from: fromState (should be a defined state, or "any")
+# from: fromState (should be a defined state, array of defined states, or "any")
# to: toState (should be a defined state)
# onStateChange: changeMethod (called on any state change)
#
root.StateMachine = class StateMachine
- constructor: (@stateMachine = {}) ->
+ constructor: (@stateMachine = {states:{}, events:{}}) ->
+ this.defineStateMachine(@stateMachine)
+
+ defineStateMachine: (@stateMachine = {states:{}, events:{}}) ->
# If array setup was used, translate it into the object setup
if @stateMachine.states.constructor.toString().indexOf('Array') isnt -1
states = @stateMachine.states
@stateMachine.states = {}
for state in states
- @stateMachine.states[state] = { active: (state == states[0]) }
-
+ @stateMachine.states[state] = { active: (state is states[0]) }
# Make sure an active state is properly set
activeStates = (state for own state, stateDef of @stateMachine.states when stateDef.active)
if activeStates.length is 0
@@ -35,12 +37,11 @@ root.StateMachine = class StateMachine
for own state in activeStates
continue if state is activeStates[0]
stateDef.active = false
-
# Define the event methods
for event, eventDef of @stateMachine.events
do(event, eventDef) =>
- this[event] = -> this.changeState(eventDef.from, eventDef.to)
-
+ this[event] = -> this.changeState(eventDef.from, eventDef.to, event)
+
currentState: ->
(state for own state, stateDef of @stateMachine.states when stateDef.active)[0]
@@ -50,7 +51,13 @@ root.StateMachine = class StateMachine
availableEvents: ->
event for own event of @stateMachine.events
- changeState: (from, to) ->
+ changeState: (from, to, event=null) ->
+ if from.constructor.toString().indexOf('Array') isnt -1
+ if from.indexOf(this.currentState()) isnt -1
+ from = this.currentState()
+ else
+ throw "Cannot change from states #{from.join(' or ')}; none are the active state!"
+
fromStateDef = @stateMachine.states[from]
toStateDef = @stateMachine.states[to]
@@ -65,9 +72,11 @@ root.StateMachine = class StateMachine
if from == 'any' then fromStateDef = @stateMachine.states[this.currentState()]
{onExit: exitMethod} = fromStateDef
- return false if guardMethod isnt undefined and guardMethod.call() is false
- exitMethod.call() if exitMethod isnt undefined
- enterMethod.call() if enterMethod isnt undefined
- @stateMachine.onStateChange.call() if @stateMachine.onStateChange isnt undefined
+ args = {from: from, to: to, event: event}
+ return false if guardMethod isnt undefined and guardMethod.call(this, args) is false
+ exitMethod.call(this, args) if exitMethod isnt undefined
+ enterMethod.call(this, args) if enterMethod isnt undefined
+ @stateMachine.onStateChange.call(this, args) if @stateMachine.onStateChange isnt undefined
fromStateDef.active = false
toStateDef.active = true
+
View
51 test/test_state_machine.coffee
@@ -119,12 +119,43 @@ vow.addBatch
'should be setup properly': (topic) ->
assert.isFunction topic.stateMachine.onStateChange
- 'onStateChange should get called state change': (topic) ->
+ 'onStateChange should get called on state change': (topic) ->
try
topic.changeState('state1', 'state2')
catch e
assert.equal e, 'onStateChangeCalled'
-
+
+ 'Callbacks should contain state and event info':
+ topic: ->
+ new StateMachine
+ states:
+ state1:
+ onExit: (args) -> this.returnedArgs = args
+ state2: {}
+ state3:
+ onEnter: (args) -> this.returnedArgs = args
+ state4:
+ guard: (args) -> this.returnedArgs = args
+ events:
+ state2to3: {from: 'state2', to: 'state3'}
+
+ 'onExit': (topic) ->
+ topic.changeState('state1', 'state2')
+ assert.equal topic.returnedArgs.from, 'state1'
+ assert.equal topic.returnedArgs.to, 'state2'
+
+ 'onEnter': (topic) ->
+ topic.state2to3()
+ assert.equal topic.returnedArgs.from, 'state2'
+ assert.equal topic.returnedArgs.to, 'state3'
+ assert.equal topic.returnedArgs.event, 'state2to3'
+
+ 'guard': (topic) ->
+ topic.changeState('state3', 'state4')
+ assert.equal topic.returnedArgs.from, 'state3'
+ assert.equal topic.returnedArgs.to, 'state4'
+ assert.equal topic.returnedArgs.event, null
+
vow.addBatch
'Events':
topic: ->
@@ -134,6 +165,7 @@ vow.addBatch
state1to2: {from:'state1', to:'state2'}
state2to1: {from:'state2', to:'state1'}
anyToState3: {from:'any', to:'state3'}
+ state2or3toState1: {from:['state2', 'state3'], to:'state1'}
'should properly change state': (topic) ->
topic.state1to2()
@@ -147,7 +179,7 @@ vow.addBatch
assert.equal 1, 2 # hrmmm... don't know how to test the error well
catch e
assert.equal e, "Cannot change from state 'state2'; it is not the active state!"
-
+
'should change from any state if "any" is the from key': (topic) ->
assert.equal topic.currentState(), 'state1' # We're in state1
topic.anyToState3()
@@ -157,5 +189,18 @@ vow.addBatch
topic.anyToState3()
assert.equal topic.currentState(), 'state3' # should change to state3
+ 'should support an array in the from key': (topic) ->
+ assert.equal topic.currentState(), 'state3' # We're in state3
+ topic.state2or3toState1()
+ assert.equal topic.currentState(), 'state1'
+ topic.state1to2()
+ assert.equal topic.currentState(), 'state2' # We're in state2
+ topic.state2or3toState1()
+ assert.equal topic.currentState(), 'state1' # We're in state1
+ try
+ topic.state2or3toState1()
+ assert.equal 1, 2 # hrmmm... don't know how to test the error well
+ catch e
+ assert.equal e, "Cannot change from states state2 or state3; none are the active state!"
exports.test_utils = vow

0 comments on commit 844542b

Please sign in to comment.