├── .github └── workflows │ └── ci.yml ├── LICENSE ├── README.md ├── spec └── fsm_spec.lua ├── statemachine-1.0.0-1.rockspec ├── statemachine.lua └── stoplight.dot.ref /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: lua-state-machine test suite 2 | on: [push] 3 | jobs: 4 | 5 | build: 6 | name: test 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@master 10 | - run: sudo apt-get -y install luajit luarocks 11 | - run: sudo luarocks install luafilesystem 12 | - run: sudo luarocks install busted 13 | - run: sudo busted spec 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Kyle Conroy 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Lua Finite State Machine 2 | ======================== 3 | 4 | This standalone lua module provides a finite state machine for your pleasure. 5 | Based **heavily** on Jake Gordon's 6 | [javascript-state-machine](https://github.com/jakesgordon/javascript-state-machine). 7 | 8 | Download 9 | ======== 10 | 11 | You can download [statemachine.lua](https://github.com/kyleconroy/lua-state-machine/raw/master/statemachine.lua). 12 | 13 | Alternatively: 14 | 15 | git clone git@github.com:kyleconroy/lua-state-machine 16 | 17 | 18 | Usage 19 | ===== 20 | 21 | In its simplest form, create a standalone state machine using: 22 | 23 | ```lua 24 | local machine = require('statemachine') 25 | 26 | local fsm = machine.create({ 27 | initial = 'green', 28 | events = { 29 | { name = 'warn', from = 'green', to = 'yellow' }, 30 | { name = 'panic', from = 'yellow', to = 'red' }, 31 | { name = 'calm', from = 'red', to = 'yellow' }, 32 | { name = 'clear', from = 'yellow', to = 'green' } 33 | }}) 34 | ``` 35 | 36 | ... will create an object with a method for each event: 37 | 38 | * fsm:warn() - transition from 'green' to 'yellow' 39 | * fsm:panic() - transition from 'yellow' to 'red' 40 | * fsm:calm() - transition from 'red' to 'yellow' 41 | * fsm:clear() - transition from 'yellow' to 'green' 42 | 43 | along with the following members: 44 | 45 | * fsm.current - contains the current state 46 | * fsm.currentTransitioningEvent - contains the current event that is in a transition. 47 | * fsm:is(s) - return true if state `s` is the current state 48 | * fsm:can(e) - return true if event `e` can be fired in the current state 49 | * fsm:cannot(e) - return true if event `e` cannot be fired in the current state 50 | 51 | Multiple 'from' and 'to' states for a single event 52 | ================================================== 53 | 54 | If an event is allowed **from** multiple states, and always transitions to the same 55 | state, then simply provide an array of states in the `from` attribute of an event. However, 56 | if an event is allowed from multiple states, but should transition **to** a different 57 | state depending on the current state, then provide multiple event entries with 58 | the same name: 59 | 60 | ```lua 61 | local machine = require('statemachine') 62 | 63 | local fsm = machine.create({ 64 | initial = 'hungry', 65 | events = { 66 | { name = 'eat', from = 'hungry', to = 'satisfied' }, 67 | { name = 'eat', from = 'satisfied', to = 'full' }, 68 | { name = 'eat', from = 'full', to = 'sick' }, 69 | { name = 'rest', from = {'hungry', 'satisfied', 'full', 'sick'}, to = 'hungry' }, 70 | }}) 71 | ``` 72 | 73 | This example will create an object with 2 event methods: 74 | 75 | * fsm:eat() 76 | * fsm:rest() 77 | 78 | The `rest` event will always transition to the `hungry` state, while the `eat` event 79 | will transition to a state that is dependent on the current state. 80 | 81 | >> NOTE: The `rest` event could use a wildcard '*' for the 'from' state if it should be 82 | allowed from any current state. 83 | 84 | >> NOTE: The `rest` event in the above example can also be specified as multiple events with 85 | the same name if you prefer the verbose approach. 86 | 87 | Callbacks 88 | ========= 89 | 90 | 4 callbacks are available if your state machine has methods using the following naming conventions: 91 | 92 | * onbefore**event** - fired before the event 93 | * onleave**state** - fired when leaving the old state 94 | * onenter**state** - fired when entering the new state 95 | * onafter**event** - fired after the event 96 | 97 | You can affect the event in 3 ways: 98 | 99 | * return `false` from an `onbeforeevent` handler to cancel the event. 100 | * return `false` from an `onleavestate` handler to cancel the event. 101 | * return `ASYNC` from an `onleavestate` or `onenterstate` handler to perform an asynchronous state transition (see next section) 102 | 103 | For convenience, the 2 most useful callbacks can be shortened: 104 | 105 | * on**event** - convenience shorthand for onafter**event** 106 | * on**state** - convenience shorthand for onenter**state** 107 | 108 | In addition, a generic `onstatechange()` callback can be used to call a single function for _all_ state changes: 109 | 110 | All callbacks will be passed the same arguments: 111 | 112 | * **self** 113 | * **event** name 114 | * **from** state 115 | * **to** state 116 | * _(followed by any arguments you passed into the original event method)_ 117 | 118 | Callbacks can be specified when the state machine is first created: 119 | 120 | ```lua 121 | local machine = require('statemachine') 122 | 123 | local fsm = machine.create({ 124 | initial = 'green', 125 | events = { 126 | { name = 'warn', from = 'green', to = 'yellow' }, 127 | { name = 'panic', from = 'yellow', to = 'red' }, 128 | { name = 'calm', from = 'red', to = 'yellow' }, 129 | { name = 'clear', from = 'yellow', to = 'green' } 130 | }, 131 | callbacks = { 132 | onpanic = function(self, event, from, to, msg) print('panic! ' .. msg) end, 133 | onclear = function(self, event, from, to, msg) print('thanks to ' .. msg) end, 134 | ongreen = function(self, event, from, to) print('green light') end, 135 | onyellow = function(self, event, from, to) print('yellow light') end, 136 | onred = function(self, event, from, to) print('red light') end, 137 | } 138 | }) 139 | 140 | fsm:warn() 141 | fsm:panic('killer bees') 142 | fsm:calm() 143 | fsm:clear('sedatives in the honey pots') 144 | ... 145 | ``` 146 | 147 | Additionally, they can be added and removed from the state machine at any time: 148 | 149 | ```lua 150 | fsm.ongreen = nil 151 | fsm.onyellow = nil 152 | fsm.onred = nil 153 | fsm.onstatechange = function(self, event, from, to) print(to) end 154 | ``` 155 | 156 | or 157 | ```lua 158 | function fsm:onstatechange(event, from, to) print(to) end 159 | ``` 160 | 161 | Asynchronous State Transitions 162 | ============================== 163 | 164 | Sometimes, you need to execute some asynchronous code during a state transition and ensure the 165 | new state is not entered until your code has completed. 166 | 167 | A good example of this is when you transition out of a `menu` state, perhaps you want to gradually 168 | fade the menu away, or slide it off the screen and don't want to transition to your `game` state 169 | until after that animation has been performed. 170 | 171 | You can now return `ASYNC` from your `onleavestate` and/or `onenterstate` handlers and the state machine 172 | will be _'put on hold'_ until you are ready to trigger the transition using the new `transition(eventName)` 173 | method. 174 | 175 | If another event is triggered during a state machine transition, the event will be triggered relative to the 176 | state the machine was transitioning to or from. Any calls to `transition` with the cancelled async event name 177 | will be invalidated. 178 | 179 | During a state change, `asyncState` will transition from `NONE` to `[event]WaitingOnLeave` to `[event]WaitingOnEnter`, 180 | looping back to `NONE`. If the state machine is put on hold, `asyncState` will pause depending on which handler 181 | you returned `ASYNC` from. 182 | 183 | Example of asynchronous transitions: 184 | 185 | ```lua 186 | local machine = require('statemachine') 187 | local manager = require('SceneManager') 188 | 189 | local fsm = machine.create({ 190 | 191 | initial = 'menu', 192 | 193 | events = { 194 | { name = 'play', from = 'menu', to = 'game' }, 195 | { name = 'quit', from = 'game', to = 'menu' } 196 | }, 197 | 198 | callbacks = { 199 | 200 | onentermenu = function() manager.switch('menu') end, 201 | onentergame = function() manager.switch('game') end, 202 | 203 | onleavemenu = function(fsm, name, from, to) 204 | manager.fade('fast', function() 205 | fsm:transition(name) 206 | end) 207 | return fsm.ASYNC -- tell machine to defer next state until we call transition (in fadeOut callback above) 208 | end, 209 | 210 | onleavegame = function(fsm, name, from, to) 211 | manager.slide('slow', function() 212 | fsm:transition(name) 213 | end) 214 | return fsm.ASYNC -- tell machine to defer next state until we call transition (in slideDown callback above) 215 | end, 216 | } 217 | }) 218 | ``` 219 | 220 | If you decide to cancel the async event, you can call `fsm.cancelTransition(eventName)` 221 | 222 | Initialization Options 223 | ====================== 224 | 225 | How the state machine should initialize can depend on your application requirements, so 226 | the library provides a number of simple options. 227 | 228 | By default, if you dont specify any initial state, the state machine will be in the `'none'` 229 | state and you would need to provide an event to take it out of this state: 230 | 231 | ```lua 232 | local machine = require('statemachine') 233 | 234 | local fsm = machine.create({ 235 | events = { 236 | { name = 'startup', from = 'none', to = 'green' }, 237 | { name = 'panic', from = 'green', to = 'red' }, 238 | { name = 'calm', from = 'red', to = 'green' }, 239 | }}) 240 | 241 | print(fsm.current) -- "none" 242 | fsm:startup() 243 | print(fsm.current) -- "green" 244 | ``` 245 | 246 | If you specify the name of your initial event (as in all the earlier examples), then an 247 | implicit `startup` event will be created for you and fired when the state machine is constructed. 248 | 249 | ```lua 250 | local machine = require('statemachine') 251 | 252 | local fsm = machine.create({ 253 | inital = 'green', 254 | events = { 255 | { name = 'panic', from = 'green', to = 'red' }, 256 | { name = 'calm', from = 'red', to = 'green' }, 257 | }}) 258 | print(fsm.current) -- "green" 259 | ``` 260 | -------------------------------------------------------------------------------- /spec/fsm_spec.lua: -------------------------------------------------------------------------------- 1 | require("busted") 2 | 3 | local machine = require("statemachine") 4 | local _ = require("luassert.match")._ 5 | 6 | describe("Lua state machine framework", function() 7 | describe("A stop light", function() 8 | local fsm 9 | local stoplight = { 10 | { name = 'warn', from = 'green', to = 'yellow' }, 11 | { name = 'panic', from = 'yellow', to = 'red' }, 12 | { name = 'calm', from = 'red', to = 'yellow' }, 13 | { name = 'clear', from = 'yellow', to = 'green' } 14 | } 15 | 16 | before_each(function() 17 | fsm = machine.create({ initial = 'green', events = stoplight }) 18 | end) 19 | 20 | it("should start as green", function() 21 | assert.are_equal(fsm.current, 'green') 22 | end) 23 | 24 | it("should not let you get to the wrong state", function() 25 | assert.is_false(fsm:panic()) 26 | assert.is_false(fsm:calm()) 27 | assert.is_false(fsm:clear()) 28 | end) 29 | 30 | it("should let you go to yellow", function() 31 | assert.is_true(fsm:warn()) 32 | assert.are_equal(fsm.current, 'yellow') 33 | end) 34 | 35 | it("should tell you what it can do", function() 36 | assert.is_true(fsm:can('warn')) 37 | assert.is_false(fsm:can('panic')) 38 | assert.is_false(fsm:can('calm')) 39 | assert.is_false(fsm:can('clear')) 40 | end) 41 | 42 | it("should tell you what it can't do", function() 43 | assert.is_false(fsm:cannot('warn')) 44 | assert.is_true(fsm:cannot('panic')) 45 | assert.is_true(fsm:cannot('calm')) 46 | assert.is_true(fsm:cannot('clear')) 47 | end) 48 | 49 | it("should support checking states", function() 50 | assert.is_true(fsm:is('green')) 51 | assert.is_false(fsm:is('red')) 52 | assert.is_false(fsm:is('yellow')) 53 | end) 54 | 55 | it("should fire callbacks", function() 56 | local fsm = machine.create({ 57 | initial = 'green', 58 | events = stoplight, 59 | callbacks = { 60 | onbeforewarn = stub.new(), 61 | onleavegreen = stub.new(), 62 | onenteryellow = stub.new(), 63 | onafterwarn = stub.new(), 64 | onstatechange = stub.new(), 65 | onyellow = stub.new(), 66 | onwarn = stub.new() 67 | } 68 | }) 69 | 70 | fsm:warn() 71 | 72 | assert.spy(fsm.onbeforewarn).was_called_with(_, 'warn', 'green', 'yellow') 73 | assert.spy(fsm.onleavegreen).was_called_with(_, 'warn', 'green', 'yellow') 74 | 75 | assert.spy(fsm.onenteryellow).was_called_with(_, 'warn', 'green', 'yellow') 76 | assert.spy(fsm.onafterwarn).was_called_with(_, 'warn', 'green', 'yellow') 77 | assert.spy(fsm.onstatechange).was_called_with(_, 'warn', 'green', 'yellow') 78 | 79 | assert.spy(fsm.onyellow).was_not_called() 80 | assert.spy(fsm.onwarn).was_not_called() 81 | end) 82 | 83 | it("should fire handlers", function() 84 | fsm.onbeforewarn = stub.new() 85 | fsm.onleavegreen = stub.new() 86 | fsm.onenteryellow = stub.new() 87 | fsm.onafterwarn = stub.new() 88 | fsm.onstatechange = stub.new() 89 | 90 | fsm.onyellow = stub.new() 91 | fsm.onwarn = stub.new() 92 | 93 | fsm:warn() 94 | 95 | assert.spy(fsm.onbeforewarn).was_called_with(_, 'warn', 'green', 'yellow') 96 | assert.spy(fsm.onleavegreen).was_called_with(_, 'warn', 'green', 'yellow') 97 | 98 | assert.spy(fsm.onenteryellow).was_called_with(_, 'warn', 'green', 'yellow') 99 | assert.spy(fsm.onafterwarn).was_called_with(_, 'warn', 'green', 'yellow') 100 | assert.spy(fsm.onstatechange).was_called_with(_, 'warn', 'green', 'yellow') 101 | 102 | assert.spy(fsm.onyellow).was_not_called() 103 | assert.spy(fsm.onwarn).was_not_called() 104 | end) 105 | 106 | it("should accept additional arguments to handlers", function() 107 | fsm.onbeforewarn = stub.new() 108 | fsm.onleavegreen = stub.new() 109 | fsm.onenteryellow = stub.new() 110 | fsm.onafterwarn = stub.new() 111 | fsm.onstatechange = stub.new() 112 | 113 | fsm:warn('bar') 114 | 115 | assert.spy(fsm.onbeforewarn).was_called_with(_, 'warn', 'green', 'yellow', 'bar') 116 | assert.spy(fsm.onleavegreen).was_called_with(_, 'warn', 'green', 'yellow', 'bar') 117 | 118 | assert.spy(fsm.onenteryellow).was_called_with(_, 'warn', 'green', 'yellow', 'bar') 119 | assert.spy(fsm.onafterwarn).was_called_with(_, 'warn', 'green', 'yellow', 'bar') 120 | assert.spy(fsm.onstatechange).was_called_with(_, 'warn', 'green', 'yellow', 'bar') 121 | end) 122 | 123 | it("should fire short handlers as a fallback", function() 124 | fsm.onyellow = stub.new() 125 | fsm.onwarn = stub.new() 126 | 127 | fsm:warn() 128 | 129 | assert.spy(fsm.onyellow).was_called_with(_, 'warn', 'green', 'yellow') 130 | assert.spy(fsm.onwarn).was_called_with(_, 'warn', 'green', 'yellow') 131 | end) 132 | 133 | it("should cancel the warn event from onleavegreen", function() 134 | fsm.onleavegreen = function(self, name, from, to) 135 | return false 136 | end 137 | 138 | local result = fsm:warn() 139 | 140 | assert.is_false(result) 141 | assert.are_equal(fsm.current, 'green') 142 | end) 143 | 144 | it("should cancel the warn event from onbeforewarn", function() 145 | fsm.onbeforewarn = function(self, name, from, to) 146 | return false 147 | end 148 | 149 | local result = fsm:warn() 150 | 151 | assert.is_false(result) 152 | assert.are_equal(fsm.current, 'green') 153 | end) 154 | 155 | it("pauses when async is passed", function() 156 | fsm.onleavegreen = function(self, name, from, to) 157 | return fsm.ASYNC 158 | end 159 | fsm.onenteryellow = function(self, name, from, to) 160 | return fsm.ASYNC 161 | end 162 | 163 | local result = fsm:warn() 164 | assert.is_true(result) 165 | assert.are_equal(fsm.current, 'green') 166 | assert.are_equal(fsm.currentTransitioningEvent, 'warn') 167 | assert.are_equal(fsm.asyncState, 'warnWaitingOnLeave') 168 | 169 | result = fsm:transition(fsm.currentTransitioningEvent) 170 | assert.is_true(result) 171 | assert.are_equal(fsm.current, 'yellow') 172 | assert.are_equal(fsm.currentTransitioningEvent, 'warn') 173 | assert.are_equal(fsm.asyncState, 'warnWaitingOnEnter') 174 | 175 | result = fsm:transition(fsm.currentTransitioningEvent) 176 | assert.is_true(result) 177 | assert.are_equal(fsm.current, 'yellow') 178 | assert.is_nil(fsm.currentTransitioningEvent) 179 | assert.are_equal(fsm.asyncState, fsm.NONE) 180 | end) 181 | 182 | it("should accept additional arguments to async handlers", function() 183 | fsm.onbeforewarn = stub.new() 184 | fsm.onleavegreen = spy.new(function(self, name, from, to, arg) 185 | return fsm.ASYNC 186 | end) 187 | fsm.onenteryellow = spy.new(function(self, name, from, to, arg) 188 | return fsm.ASYNC 189 | end) 190 | fsm.onafterwarn = stub.new() 191 | fsm.onstatechange = stub.new() 192 | 193 | fsm:warn('bar') 194 | assert.spy(fsm.onbeforewarn).was_called_with(_, 'warn', 'green', 'yellow', 'bar') 195 | assert.spy(fsm.onleavegreen).was_called_with(_, 'warn', 'green', 'yellow', 'bar') 196 | 197 | fsm:transition(fsm.currentTransitioningEvent) 198 | assert.spy(fsm.onenteryellow).was_called_with(_, 'warn', 'green', 'yellow', 'bar') 199 | 200 | fsm:transition(fsm.currentTransitioningEvent) 201 | assert.spy(fsm.onafterwarn).was_called_with(_, 'warn', 'green', 'yellow', 'bar') 202 | assert.spy(fsm.onstatechange).was_called_with(_, 'warn', 'green', 'yellow', 'bar') 203 | end) 204 | 205 | it("should properly transition when another event happens during leave async", function() 206 | local tempStoplight = {} 207 | for _, event in ipairs(stoplight) do 208 | table.insert(tempStoplight, event) 209 | end 210 | table.insert(tempStoplight, { name = "panic", from = "green", to = "red" }) 211 | 212 | local fsm = machine.create({ 213 | initial = 'green', 214 | events = tempStoplight 215 | }) 216 | 217 | fsm.onleavegreen = function(self, name, from, to) 218 | return fsm.ASYNC 219 | end 220 | 221 | fsm:warn() 222 | 223 | local result = fsm:panic() 224 | local transitionResult = fsm:transition(fsm.currentTransitioningEvent) 225 | 226 | assert.is_true(result) 227 | assert.is_true(transitionResult) 228 | assert.is_nil(fsm.currentTransitioningEvent) 229 | assert.are_equal(fsm.asyncState, fsm.NONE) 230 | assert.are_equal(fsm.current, 'red') 231 | end) 232 | 233 | it("should properly transition when another event happens during enter async", function() 234 | fsm.onenteryellow = function(self, name, from, to) 235 | return fsm.ASYNC 236 | end 237 | 238 | fsm:warn() 239 | 240 | local result = fsm:panic() 241 | 242 | assert.is_true(result) 243 | assert.is_nil(fsm.currentTransitioningEvent) 244 | assert.are_equal(fsm.asyncState, fsm.NONE) 245 | assert.are_equal(fsm.current, 'red') 246 | end) 247 | 248 | it("should properly cancel the transition if asked", function() 249 | fsm.onleavegreen = function(self, name, from, to) 250 | return fsm.ASYNC 251 | end 252 | 253 | fsm:warn() 254 | fsm:cancelTransition(fsm.currentTransitioningEvent) 255 | 256 | assert.is_nil(fsm.currentTransitioningEvent) 257 | assert.are_equal(fsm.asyncState, fsm.NONE) 258 | assert.are_equal(fsm.current, 'green') 259 | 260 | fsm.onleavegreen = nil 261 | fsm.onenteryellow = function(self, name, from, to) 262 | return fsm.ASYNC 263 | end 264 | 265 | fsm:warn() 266 | fsm:cancelTransition(fsm.currentTransitioningEvent) 267 | 268 | assert.is_nil(fsm.currentTransitioningEvent) 269 | assert.are_equal(fsm.asyncState, fsm.NONE) 270 | assert.are_equal(fsm.current, 'yellow') 271 | end) 272 | 273 | it("todot generates dot file (graphviz)", function() 274 | assert.has_no_error(function() 275 | fsm:todot('stoplight.dot') 276 | end) 277 | assert.is_equal(io.open('stoplight.dot'):read('*a'), io.open('stoplight.dot.ref'):read('*a')) 278 | end) 279 | end) 280 | 281 | describe("A monster", function() 282 | local fsm 283 | local monster = { 284 | { name = 'eat', from = 'hungry', to = 'satisfied' }, 285 | { name = 'eat', from = 'satisfied', to = 'full' }, 286 | { name = 'eat', from = 'full', to = 'sick' }, 287 | { name = 'rest', from = {'hungry', 'satisfied', 'full', 'sick'}, to = 'hungry' } 288 | } 289 | 290 | before_each(function() 291 | fsm = machine.create({ initial = 'hungry', events = monster }) 292 | end) 293 | 294 | it("can eat unless it is sick", function() 295 | assert.are_equal(fsm.current, 'hungry') 296 | assert.is_true(fsm:can('eat')) 297 | fsm:eat() 298 | assert.are_equal(fsm.current, 'satisfied') 299 | assert.is_true(fsm:can('eat')) 300 | fsm:eat() 301 | assert.are_equal(fsm.current, 'full') 302 | assert.is_true(fsm:can('eat')) 303 | fsm:eat() 304 | assert.are_equal(fsm.current, 'sick') 305 | assert.is_false(fsm:can('eat')) 306 | end) 307 | 308 | it("can always rest", function() 309 | assert.are_equal(fsm.current, 'hungry') 310 | assert.is_true(fsm:can('rest')) 311 | fsm:eat() 312 | assert.are_equal(fsm.current, 'satisfied') 313 | assert.is_true(fsm:can('rest')) 314 | fsm:eat() 315 | assert.are_equal(fsm.current, 'full') 316 | assert.is_true(fsm:can('rest')) 317 | fsm:eat() 318 | assert.are_equal(fsm.current, 'sick') 319 | assert.is_true(fsm:can('rest')) 320 | fsm:rest() 321 | assert.are_equal(fsm.current, 'hungry') 322 | end) 323 | end) 324 | end) 325 | -------------------------------------------------------------------------------- /statemachine-1.0.0-1.rockspec: -------------------------------------------------------------------------------- 1 | package = "statemachine" 2 | version = "1.0.0-1" 3 | source = { 4 | url = "https://github.com/kyleconroy/lua-state-machine/archive/v1.0.0.tar.gz", 5 | dir = "lua-state-machine-1.0.0" 6 | } 7 | description = { 8 | summary = "A finite state machine micro framework", 9 | detailed = [[ 10 | This standalone module provides a finite state machine for your pleasure. 11 | ]], 12 | homepage = "https://github.com/kyleconroy/lua-state-machine", 13 | license = "MIT/X11" 14 | } 15 | dependencies = { 16 | "lua >= 5.1" 17 | } 18 | build = { 19 | type = "builtin", 20 | modules = { 21 | ["statemachine"] = "statemachine.lua" 22 | }, 23 | copy_directories = {} 24 | } 25 | 26 | -------------------------------------------------------------------------------- /statemachine.lua: -------------------------------------------------------------------------------- 1 | local machine = {} 2 | machine.__index = machine 3 | 4 | local NONE = "none" 5 | local ASYNC = "async" 6 | 7 | local function call_handler(handler, params) 8 | if handler then 9 | return handler(unpack(params)) 10 | end 11 | end 12 | 13 | local function create_transition(name) 14 | local can, to, from, params 15 | 16 | local function transition(self, ...) 17 | if self.asyncState == NONE then 18 | can, to = self:can(name) 19 | from = self.current 20 | params = { self, name, from, to, ...} 21 | 22 | if not can then return false end 23 | self.currentTransitioningEvent = name 24 | 25 | local beforeReturn = call_handler(self["onbefore" .. name], params) 26 | local leaveReturn = call_handler(self["onleave" .. from], params) 27 | 28 | if beforeReturn == false or leaveReturn == false then 29 | return false 30 | end 31 | 32 | self.asyncState = name .. "WaitingOnLeave" 33 | 34 | if leaveReturn ~= ASYNC then 35 | transition(self, ...) 36 | end 37 | 38 | return true 39 | elseif self.asyncState == name .. "WaitingOnLeave" then 40 | self.current = to 41 | 42 | local enterReturn = call_handler(self["onenter" .. to] or self["on" .. to], params) 43 | 44 | self.asyncState = name .. "WaitingOnEnter" 45 | 46 | if enterReturn ~= ASYNC then 47 | transition(self, ...) 48 | end 49 | 50 | return true 51 | elseif self.asyncState == name .. "WaitingOnEnter" then 52 | call_handler(self["onafter" .. name] or self["on" .. name], params) 53 | call_handler(self["onstatechange"], params) 54 | self.asyncState = NONE 55 | self.currentTransitioningEvent = nil 56 | return true 57 | else 58 | if string.find(self.asyncState, "WaitingOnLeave") or string.find(self.asyncState, "WaitingOnEnter") then 59 | self.asyncState = NONE 60 | transition(self, ...) 61 | return true 62 | end 63 | end 64 | 65 | self.currentTransitioningEvent = nil 66 | return false 67 | end 68 | 69 | return transition 70 | end 71 | 72 | local function add_to_map(map, event) 73 | if type(event.from) == 'string' then 74 | map[event.from] = event.to 75 | else 76 | for _, from in ipairs(event.from) do 77 | map[from] = event.to 78 | end 79 | end 80 | end 81 | 82 | function machine.create(options) 83 | assert(options.events) 84 | 85 | local fsm = {} 86 | setmetatable(fsm, machine) 87 | 88 | fsm.options = options 89 | fsm.current = options.initial or 'none' 90 | fsm.asyncState = NONE 91 | fsm.events = {} 92 | 93 | for _, event in ipairs(options.events or {}) do 94 | local name = event.name 95 | fsm[name] = fsm[name] or create_transition(name) 96 | fsm.events[name] = fsm.events[name] or { map = {} } 97 | add_to_map(fsm.events[name].map, event) 98 | end 99 | 100 | for name, callback in pairs(options.callbacks or {}) do 101 | fsm[name] = callback 102 | end 103 | 104 | return fsm 105 | end 106 | 107 | function machine:is(state) 108 | return self.current == state 109 | end 110 | 111 | function machine:can(e) 112 | local event = self.events[e] 113 | local to = event and event.map[self.current] or event.map['*'] 114 | return to ~= nil, to 115 | end 116 | 117 | function machine:cannot(e) 118 | return not self:can(e) 119 | end 120 | 121 | function machine:todot(filename) 122 | local dotfile = io.open(filename,'w') 123 | assert(dotfile~=nil) 124 | dotfile:write('digraph {\n') 125 | local transition = function(event,from,to) 126 | dotfile:write(string.format('%s -> %s [label=%s];\n',from,to,event)) 127 | end 128 | for _, event in pairs(self.options.events) do 129 | if type(event.from) == 'table' then 130 | for _, from in ipairs(event.from) do 131 | transition(event.name,from,event.to) 132 | end 133 | else 134 | transition(event.name,event.from,event.to) 135 | end 136 | end 137 | dotfile:write('}\n') 138 | dotfile:close() 139 | end 140 | 141 | function machine:transition(event) 142 | if self.currentTransitioningEvent == event then 143 | return self[self.currentTransitioningEvent](self) 144 | end 145 | end 146 | 147 | function machine:cancelTransition(event) 148 | if self.currentTransitioningEvent == event then 149 | self.asyncState = NONE 150 | self.currentTransitioningEvent = nil 151 | end 152 | end 153 | 154 | machine.NONE = NONE 155 | machine.ASYNC = ASYNC 156 | 157 | return machine 158 | -------------------------------------------------------------------------------- /stoplight.dot.ref: -------------------------------------------------------------------------------- 1 | digraph { 2 | green -> yellow [label=warn]; 3 | yellow -> red [label=panic]; 4 | red -> yellow [label=calm]; 5 | yellow -> green [label=clear]; 6 | } 7 | --------------------------------------------------------------------------------