├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── examples ├── prime.ts ├── rewind.ts └── simple.ts ├── package-lock.json ├── package.json ├── spec └── when.md ├── src ├── historyManager.ts ├── index.ts ├── interfaces.ts ├── metadataKeys.ts ├── stateMachine.ts └── util.ts ├── tests ├── prime.test.ts ├── recombination.test.ts └── stateMachine.test.ts ├── tsconfig.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset=utf-8 3 | end_of_line=lf 4 | insert_final_newline=false 5 | indent_style=space 6 | indent_size=4 7 | 8 | [{.babelrc,.prettierrc,.stylelintrc,.eslintrc,jest.config,*.json,*.jsb3,*.jsb2,*.bowerrc}] 9 | indent_style=space 10 | indent_size=2 11 | 12 | [*.js] 13 | indent_style=space 14 | indent_size=2 15 | 16 | [{*.ats,*.ts}] 17 | indent_style=space 18 | indent_size=2 19 | 20 | [{tsconfig.app.json,tsconfig.spec.json,tsconfig.json,tsconfig.e2e.json}] 21 | indent_style=space 22 | indent_size=2 23 | 24 | [{.analysis_options,*.yml,*.yaml}] 25 | indent_style=space 26 | indent_size=2 27 | 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | npm-debug.log* 3 | node_modules 4 | coverage 5 | .nyc_output 6 | .DS_Store 7 | .vscode 8 | .idea 9 | dist 10 | compiled 11 | .awcache 12 | .rpt2_cache 13 | docs 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | tests/**.ts 3 | dist/lib/examples/ 4 | dist/types/examples/ 5 | node_modules 6 | *.log 7 | npm-debug.log* 8 | coverage 9 | .nyc_output 10 | .DS_Store 11 | .vscode 12 | .idea 13 | compiled 14 | .awcache 15 | .rpt2_cache 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: nodejs 2 | 3 | node_js: 4 | - "8.12.0" 5 | - "10.13.0" 6 | 7 | cache: 8 | directories: 9 | - node_modules 10 | 11 | before_script: 12 | - nvm install stable 13 | - nvm use stable 14 | - npm install 15 | - npm run build 16 | 17 | script: 18 | - npm run test 19 | - npm run test:coveralls 20 | 21 | after_success: 22 | - npm pack 23 | 24 | deploy: 25 | - provider: pages 26 | skip_cleanup: true 27 | local_dir: docs/ 28 | github_token: $GITHUB_TOKEN 29 | on: 30 | tags: true 31 | - provider: releases 32 | api_key: $GITHUB_TOKEN 33 | file_glob: true 34 | file: "{when-ts}-*.tgz" 35 | skip_cleanup: true 36 | on: 37 | tags: true 38 | - provider: npm 39 | email: voodooattack@hotmail.com 40 | api_key: $NPM_TOKEN 41 | skip_cleanup: true 42 | on: 43 | tags: true 44 | repo: voodooattack/when-ts 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Abdullah A. Hassan 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 | # When: TypeScript Implementation 2 | ##### A software design pattern for building event-based recombinant state machines 3 | 4 | [![npm](https://img.shields.io/npm/v/when-ts.svg)](https://www.npmjs.com/package/when-ts) 5 | [![GitHub license](https://img.shields.io/github/license/voodooattack/when-ts.svg)](https://github.com/voodooattack/when-ts/blob/master/LICENSE) 6 | [![GitHub issues](https://img.shields.io/github/issues/voodooattack/when-ts.svg)](https://github.com/voodooattack/when-ts/issues) 7 | [![Build Status](https://travis-ci.org/voodooattack/when-ts.svg?branch=master)](https://travis-ci.org/voodooattack/when-ts) [![Coverage Status](https://coveralls.io/repos/github/voodooattack/when-ts/badge.svg)](https://coveralls.io/github/voodooattack/when-ts) 8 | ![npm type definitions](https://img.shields.io/npm/types/when-ts.svg) 9 | 10 | ### Introduction 11 | 12 | **The latest version of this README can be found in the [`devel` branch](https://github.com/voodooattack/when-ts/blob/devel/README.md), please read the spec there if that's what you're after.** 13 | 14 | The spec for the abstract syntax and the design pattern itself can be found in [the spec subdirectory](spec/when.md). Please read the specs before delving into the implementation itself to get a good understanding of how things work. 15 | 16 | This is a reference implementation for a new software design pattern that allows for composable event-based state machines with complete (including temporal) control over the state. 17 | 18 | 19 | #### Features: 20 | 21 | - Discrete: if your actions only deal with the state object, then every state transition is 100% predictable. 22 | - Temporal: time can be rewound at any given moment (tick) by default, and the state machine will transition to a previously known state in time, along with any future information in the form of an optional state mutation to apply. 23 | - Recombinant: the pattern is based on [gene expression](https://en.wikipedia.org/wiki/Gene_expression), and since state machines are composed of events (`condition -> action` pairs) that are quite similar to how real genes are theorised to work (`activation region -> coding region`), this means that genetic recombination can be applied to `when` state machines by transferring new events from one machine to another. Mutating the machine (DNA) by transferring condition/action pairs (genes) from one machine to the other to introduce new behaviour. 24 | 25 | #### Possible Proposals 26 | 27 | Here are some possible expansions on the idea. These require further discussion before they're mature enough to include: 28 | 29 | - Sexual reproduction of state machines: possible use of a similar mechanic to the one used in organic cells to combine two different programs (DNA) by randomly selecting an equal half of each. 30 | - Mutation: Possible, but difficult since we can't swap code like basepairs. The simplest possible mutation would be a random swap of conditions between two randomly selected actions. 31 | 32 | This would all lead to more emergent behaviour in agents produced by recombination. 33 | 34 | ## When: TypeScript Implementation 35 | 36 | ### Installation 37 | 38 | You need to install `reflect-metadata` in your project. 39 | 40 | `npm install when-ts reflect-metadata` 41 | 42 | Additionally, you must add the following to your project's `tsconfig.json` for the TypeScript decorator to work: 43 | 44 | ```json 45 | { 46 | "experimentalDecorators": true, 47 | "emitDecoratorMetadata": true 48 | } 49 | ``` 50 | 51 | ### API 52 | 53 | See the [API documentation](https://voodooattack.github.io/when-ts/) for more information. 54 | 55 | ### Usage 56 | 57 | Some examples are located in in [examples/](examples). 58 | 59 | #### Simple example: 60 | 61 | ```typescript 62 | import { StateMachine, MachineState, when } from 'when-ts'; 63 | 64 | interface State extends MachineState { // the state of our program 65 | value: number; // a counter that will be incremented once per tick 66 | } 67 | 68 | class TestMachine extends StateMachine { 69 | constructor() { 70 | super({ value: 0 }); // pass the initial state to the event machine 71 | } 72 | 73 | @when(true) // define a condition for this block to execute, in this case always 74 | reportOncePerTick(s: State, m: TestMachine) { 75 | console.log(`beginning tick #${m.history.tick} with state`, s); 76 | } 77 | 78 | @when(state => state.value < 5) 79 | incrementOncePerTick(s: State) { 80 | return { value: s.value + 1 }; 81 | } 82 | 83 | @when(state => state.value >= 5) 84 | exitWhenDone(s: State, m: TestMachine) { 85 | console.log(`finished on tick #${m.history.tick}, exiting`, s); 86 | m.exit(); // exit the state machine 87 | } 88 | } 89 | 90 | const test = new TestMachine(); 91 | 92 | const result = test.run(); // this will block until the machine exits, unlike `.step()` 93 | 94 | console.log('state machine exits with:', result); 95 | ``` 96 | 97 | #### Brute-forcing primes 98 | 99 | The same prime machine from the spec, implemented in TypeScript. This one uses the `input` feature. 100 | 101 | A better implementation exists in [examples/prime.ts](examples/prime.ts)! 102 | 103 | ```typescript 104 | import { StateMachine, when, input, MachineState, MachineInputSource, StateObject } from 'when-ts'; 105 | 106 | interface PrimeState extends MachineState { 107 | counter: number; 108 | current: number; 109 | primes: number[]; 110 | } 111 | 112 | interface IPrimeInputSource extends MachineInputSource { 113 | readonly maxPrimes: number; 114 | } 115 | 116 | class PrimeInputSource implements IPrimeInputSource { 117 | @input('once') // mark as an input that's only read during startup. 118 | public readonly maxPrimes: number; 119 | constructor(primes = 10) { 120 | this.maxPrimes = primes; 121 | } 122 | } 123 | 124 | class PrimeMachine extends StateMachine { 125 | constructor(inputSource: IPrimeInputSource) { 126 | // pass the initial state 127 | super({ counter: 2, current: 3, primes: [2] }, inputSource); 128 | } 129 | 130 | // increment the counter with every tick 131 | @when(state => state.counter < state.current) 132 | .unless(state => state.primes.length >= state.maxPrimes) 133 | incrementCounterOncePerTick({ counter }: StateObject) { 134 | return { counter: counter + 1 }; 135 | } 136 | 137 | @when(state => state.counter < state.current && state.current % state.counter === 0) 138 | .unless(state => state.primes.length >= state.maxPrimes) 139 | resetNotPrime({ counter, primes, current }: StateObject) { 140 | return { counter: 2, current: current + 1 }; 141 | } 142 | 143 | @when(state => state.counter >= state.current) 144 | .unless(state => state.primes.length >= state.maxPrimes) 145 | capturePrime({ counter, primes, current }: StateObject) { 146 | return { counter: 2, current: current + 1, primes: [...primes, current] }; 147 | } 148 | 149 | // this explicit exit clause is not required because `unless` will cause the machine to implicitly exit above 150 | // @when(state => state.primes.length >= state.maxPrimes) 151 | // exitMachine(_, m: StateMachine) { 152 | // m.exit(); 153 | // } 154 | } 155 | 156 | const inputSource = new PrimeInputSource(10); 157 | const primeMachine = new PrimeMachine(inputSource); 158 | 159 | const result = primeMachine.run(); 160 | 161 | if (result) 162 | console.log(result!.primes); 163 | 164 | ``` 165 | 166 | Output: 167 | 168 | ```json 169 | { 170 | "counter": 2, 171 | "current": 30, 172 | "primes": [ 2, 3, 5, 7, 11, 13, 17, 19, 23, 29 ] 173 | } 174 | ``` 175 | 176 | ### Contributions 177 | 178 | All contributions and pull requests are welcome. 179 | 180 | If you have something to suggest or an idea you'd like to discuss, then please submit an issue or a pull request. 181 | 182 | Note: All active development happens in the `devel` branch. Please commit your changes using `npm run commit` to trigger `conventional-changelog`. 183 | 184 | ### License (MIT) 185 | 186 | Copyright (c) 2018 Abdullah A. Hassan 187 | 188 | Permission is hereby granted, free of charge, to any person obtaining a copy 189 | of this software and associated documentation files (the "Software"), to deal 190 | in the Software without restriction, including without limitation the rights 191 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 192 | copies of the Software, and to permit persons to whom the Software is 193 | furnished to do so, subject to the following conditions: 194 | 195 | The above copyright notice and this permission notice shall be included in all 196 | copies or substantial portions of the Software. 197 | 198 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 199 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 200 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 201 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 202 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 203 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 204 | SOFTWARE. 205 | -------------------------------------------------------------------------------- /examples/prime.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * prime.ts: A `when` state machine to discover primes through brute-force. 3 | * 4 | * Build this by typing `npm run build` and run it with the following command: 5 | * `node dist/lib/examples/prime.js ` where `n` is an integer specifying the 6 | * maximum number of primes to look for before stopping. 7 | * It will find the first 10 primes if you omit the argument. 8 | * Output is the total time spent (in ticks), number of primes, the primes themselves, 9 | * and time spent finding every individual prime. 10 | */ 11 | 12 | import { input, MachineState, StateMachine, StateObject, when } from '../src'; 13 | 14 | interface IPrimeInputSource { 15 | // total number of primes to find in a given run (readonly) 16 | readonly numberOfPrimes: number; 17 | } 18 | 19 | class PrimeInputSource implements IPrimeInputSource { 20 | @input('once') 21 | public readonly numberOfPrimes: number = 10; 22 | 23 | constructor(numberOfPrimes: number = 10) 24 | { 25 | this.numberOfPrimes = numberOfPrimes; 26 | } 27 | } 28 | 29 | /** 30 | * This state object defines the variables this machine will use for its state. 31 | */ 32 | interface PrimeState extends MachineState { 33 | // the current number being checked in any given `tick` 34 | counter: number; 35 | // number to start counting from 36 | current: number; 37 | // stored primes found so far 38 | primes: number[]; 39 | // tick count for every prime stored 40 | times: number[]; 41 | } 42 | 43 | /** 44 | * A simple state machine for brute-forcing primes. 45 | */ 46 | class PrimeMachine extends StateMachine { 47 | constructor(inputSource: PrimeInputSource) { 48 | // pass the initial state to the StateMachine 49 | super({ counter: 2, current: 3, primes: [2], times: [0] }, inputSource); 50 | } 51 | 52 | // increment the counter with every tick 53 | @when(state => state.counter < state.current) 54 | // this inhibit cause execution to end when we've found the required number of primes 55 | .unless(state => state.primes.length >= state.numberOfPrimes) 56 | incrementCounterOncePerTick({ counter }: StateObject) { 57 | return { counter: counter + 1 }; 58 | } 59 | 60 | // this will only be triggered if the current number fails the prime check 61 | @when( 62 | state => state.counter < state.current && state.current % state.counter === 0) 63 | .unless(state => state.primes.length >= state.numberOfPrimes) 64 | resetNotPrime({ current }: StateObject) { 65 | return { 66 | counter: 2, // reset the counter 67 | current: current + 1 // skip this number 68 | }; 69 | } 70 | 71 | // this will only be triggered when all checks have passed (the number is a confirmed prime) 72 | @when(state => state.counter === state.current) 73 | .unless(state => state.primes.length >= state.numberOfPrimes) 74 | capturePrime({ primes, current, times }: StateObject, { history }: PrimeMachine) { 75 | return { 76 | counter: 2, // reset the counter 77 | current: current + 1, // increment the target 78 | primes: [...primes, current], // store the new prime 79 | times: [...times, history.tick] // store the current tick count 80 | }; 81 | } 82 | } 83 | 84 | // obtain the supplied count or default to 10 85 | const count = process.argv[2] ? parseInt(process.argv[2], 10) : 10; 86 | // crate an instance of the prime machine 87 | const primeMachine = new PrimeMachine(new PrimeInputSource(count)); 88 | // let it execute to a conclusion 89 | const result = primeMachine.run(); 90 | 91 | if (result) { 92 | // number of primes 93 | console.log(`N = ${count}`); 94 | // total execution time 95 | console.log( 96 | `O(N) = ${primeMachine.history.tick} ticks` 97 | ); 98 | // the primes themselves 99 | console.log( 100 | `P(N) =`, result!.primes 101 | ); 102 | // prime times 103 | console.log( 104 | `T(P) =`, 105 | result.times 106 | ); 107 | // time spent per prime 108 | console.log( 109 | `T(P) - T(P-1) =`, 110 | result.times.map( 111 | (t, i, a) => t - (a[--i] || 0)) 112 | ); 113 | } 114 | -------------------------------------------------------------------------------- /examples/rewind.ts: -------------------------------------------------------------------------------- 1 | import { StateMachine, when } from '../src'; 2 | 3 | type State = { // the state of our program 4 | value: number; // a counter that will be incremented once per tick 5 | cycle: number; // a counter for rewinds 6 | } 7 | 8 | class TestMachine extends StateMachine { 9 | constructor() { 10 | super({ value: 0, cycle: 0 }); // pass the initial state to the event machine 11 | } 12 | 13 | @when(true) // define a condition for this block to execute, in this case always 14 | reportOncePerTick(s: State, m: TestMachine) { 15 | console.log(`beginning tick #${m.history.tick} with state`, s); 16 | } 17 | 18 | @when(state => state.value < 5) // this only executes when `currentValue` is less than 5 19 | incrementOncePerTick(s: State) { // increment `currentValue` once per tick 20 | return { value: s.value + 1 }; 21 | } 22 | 23 | @when(state => state.value >= 5) // this will only execute when `currentValue` is >= 5 24 | exitWhenDone(s: State, m: TestMachine) { 25 | console.log(`finished on tick #${m.history.tick}, exiting`, s); 26 | if (s.cycle < 10) { // rewind the program 10 times 27 | m.history.rewind(Infinity, { cycle: s.cycle + 1 }); // rewind the state machine with a side-effect 28 | } 29 | else if (s.cycle >= 10) { 30 | m.exit(); 31 | } // exit the state machine 32 | } 33 | } 34 | 35 | const test = new TestMachine(); 36 | 37 | const result = test.run(); // this does will block until the machine exits, unlike `.step()` 38 | 39 | console.log('state machine exits with:', result); 40 | -------------------------------------------------------------------------------- /examples/simple.ts: -------------------------------------------------------------------------------- 1 | import { StateMachine, when } from '../src'; 2 | 3 | type State = { // the state of our program 4 | value: number; // a counter that will be incremented once per tick 5 | } 6 | 7 | class TestMachine extends StateMachine { 8 | constructor() { 9 | super({ value: 0 }); // pass the initial state to the event machine 10 | } 11 | 12 | @when(true) // define a condition for this block to execute, in this case always 13 | reportOncePerTick(s: State, m: TestMachine) { 14 | console.log(`beginning tick #${m.history.tick} with state`, s); 15 | } 16 | 17 | @when(state => state.value < 5) // this only executes when `currentValue` is less than 5 18 | incrementOncePerTick(s: State) { // increment `currentValue` once per tick 19 | return { value: s.value + 1 }; 20 | } 21 | 22 | @when(state => state.value >= 5) // this will only execute when `currentValue` is >= 5 23 | exitWhenDone(s: State, m: TestMachine) { 24 | console.log(`finished on tick #${m.history.tick}, exiting`, s); 25 | if (m.history.tick >= 5) { 26 | m.exit(); 27 | } // exit the state machine 28 | } 29 | } 30 | 31 | const test = new TestMachine(); 32 | 33 | const result = test.run(); // this does will block until the machine exits, unlike `.step()` 34 | 35 | console.log('state machine exits with:', result); 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "when-ts", 3 | "version": "1.0.2", 4 | "description": "When: A software design pattern for building event-based recombinant state machines.", 5 | "main": "dist/lib/src/index.js", 6 | "types": "dist/types/src/index.d.ts", 7 | "scripts": { 8 | "lint": "tslint --project tsconfig.json -t codeFrame 'src/**/*.ts' 'test/**/*.ts'", 9 | "clean": "rimraf dist/", 10 | "prebuild": "npm run clean && npm run lint", 11 | "build": "tsc", 12 | "typedoc": "typedoc --out docs --target es6 --theme minimal --mode file src", 13 | "prepublish": "npm run build && npm run typedoc && npm run test", 14 | "test": "jest --coverage", 15 | "test:coveralls": "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js", 16 | "commit": "npm run lint && git-cz" 17 | }, 18 | "engines": { 19 | "node": ">=8.12.0" 20 | }, 21 | "devDependencies": { 22 | "@types/jest": "^23.1.6", 23 | "@types/node": "^10.5.2", 24 | "coveralls": "^3.0.2", 25 | "cz-conventional-changelog": "^2.1.0", 26 | "jest": "^23.4.1", 27 | "reflect-metadata": "^0.1.12", 28 | "rimraf": "^2.6.2", 29 | "ts-jest": "^23.0.0", 30 | "ts-node": "^7.0.0", 31 | "tslint": "^5.10.0", 32 | "tslint-config-prettier": "^1.13.0", 33 | "tslint-config-standard": "^8.0.1", 34 | "typedoc": "^0.13.0", 35 | "typescript": "^3.0.3" 36 | }, 37 | "peerDependencies": { 38 | "reflect-metadata": "^0.1.12" 39 | }, 40 | "repository": { 41 | "type": "git", 42 | "url": "git+https://github.com/voodooattack/when-ts.git" 43 | }, 44 | "homepage": "https://github.com/voodooattack/when-ts#readme", 45 | "author": "Abdullah Ali ", 46 | "license": "MIT", 47 | "jest": { 48 | "transform": { 49 | ".(ts|tsx)": "ts-jest" 50 | }, 51 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", 52 | "moduleFileExtensions": [ 53 | "ts", 54 | "tsx", 55 | "js" 56 | ], 57 | "coveragePathIgnorePatterns": [ 58 | "/node_modules/", 59 | "/tests/" 60 | ], 61 | "coverageThreshold": { 62 | "global": { 63 | "branches": 80, 64 | "functions": 85, 65 | "lines": 85, 66 | "statements": 85 67 | } 68 | }, 69 | "collectCoverage": true 70 | }, 71 | "keywords": [ 72 | "state", 73 | "abstract", 74 | "design pattern", 75 | "state machine", 76 | "typescript", 77 | "decorator", 78 | "event-based", 79 | "recombination", 80 | "immutable", 81 | "temporal-model" 82 | ], 83 | "config": { 84 | "commitizen": { 85 | "path": "./node_modules/cz-conventional-changelog" 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /spec/when.md: -------------------------------------------------------------------------------- 1 | # When: recombinant event-based state machines. 2 | 3 | ### Pattern 4 | 5 | *The following is a description of the pattern itself, and not any specific implementation.* 6 | 7 | This pattern itself is completely generic and can be implemented in any programming language available today with varying degrees of ease, depending on the features of the target language. 8 | 9 | #### Program state 10 | 11 | A `MachineState` consists of user-defined global variables (and is passed to every condition and action as the first argument in the reference implementation). 12 | 13 | An external tick counter (`history.tick`) exists and can be considered part of the state (but is not included inside the state object). It is a special variable that is automatically incremented with every new tick. Can be used to reference discrete points in time. 14 | 15 | #### Conditions and Actions 16 | 17 | All when programs consist of `condition` and `action` pairs. The condition is a and expression that must evaluate to a boolean value. 18 | 19 | When a `condition` evaluates to `true`, the associated `action` is then executed. 20 | 21 | `actions` can modify the variables in the current state, but any modifications they make during a `tick` will be applied to the `state` only on the next `tick`. 22 | 23 | If a conflict between two or more `actions` trying to modify the same variable during a `tick` happens, the last `action` to be invoked will override the previous value set by any earlier `actions` during the current `tick`. 24 | 25 | #### Main loop 26 | 27 | The goal of the main loop is to move execution forward by mutating the current `state`. 28 | 29 | To do this, `when` implements a loop that constantly evaluates a set of rules (`program`). Every iteration of this loop is called a `tick`, and whenever a condition evaluates to `true`, the `action` associated with the condition is evaluated. `actions` can modify non-constant global variables with values for the next `state`. 30 | 31 | Note that any new mutations caused by actions will only appear during the next `tick`. This is to prevent interactions between different `actions` during the same `tick`. 32 | 33 | If multiple actions try to modify the same variable during the same `tick`, the last `action` to execute takes precedence. 34 | 35 | #### Finite State Machines 36 | 37 | By default, the state machines built with `when` will be finite, this means that the main loop will halt by default if it exhausts all possible conditions and none evaluate to `true` and trigger an action during the same `tick`. 38 | 39 | This prevents the program from running forever by default, and can be disabled as needed. 40 | 41 | ### State Manager 42 | 43 | - A State Manager (`history`) is accessible from events. It is responsible for managing an array of previous states (`history.records`), in which states are recorded as the program advances. 44 | 45 | - A state machine can exit by calling `exit()` from any event, the returned value is the last recorded state. A single argument can be passed to `exit()` to override the returned state. 46 | 47 | - Events can use `history.tick` to access the current tick counter. 48 | 49 | - Events can access the last recorded states from `history.currentState`. 50 | 51 | - Events can access the next state being actively mutated by the current tick through the read-only property `history.nextState`. 52 | 53 | - The state can be rewound to a previously recorded state using the `history.rewind(t)` method. `history.rewind(2)` will cause the program to rewind to the discrete tick `t` (the tick counter will be decremented as needed). If this occurs inside an event handler, further events will not be processed. 54 | 55 | - `history.rewind` accepts a second parameter with optional variable to pass after rewinding to the past state, `history.rewind(2, { backToTheFuture: true })` will rewind to tick `2` and mutate the past state by setting the variable `backToTheFuture` to `true`. 56 | 57 | - State history can be erased at any time using `history.clear();`. 58 | 59 | - State recording can be configured or disabled at any time by manipulating `history.limit`. 60 | 61 | - Setting a finite `limit` during startup is strongly advised. `history.limit` defaults to `Infinity`. 62 | 63 | **Examples of `limit`:** 64 | 65 | - `history.limit = Infinity;` Record an infinite amount of state. (This is the default, which may cause memory issues if your state objects are very big and/or your program stays running for a long time) 66 | 67 | - `history.limit = 4;` Only record the most recent 4 states. Discards any stored older states. 68 | 69 | - `history.limit = 0;` No further state recording allowed, and acts the same as `history.limit = 1`. Discards any older history, and `history.record` will only show the previous state. 70 | 71 | ### External inputs 72 | 73 | `when` supports external inputs via the `@input` decorator. External inputs are readonly variables that are recorded as part of the state, but never manually updated. 74 | 75 | ### Note on Recombination 76 | 77 | This is not part of the current spec, but is currently offered by the TypeScript reference implementation. You can combine any two machines by calling `machine1.recombine(machine2)`, see the [TypeScript API documentation](https://voodooattack.github.io/when-ts/) for more details. 78 | 79 | #### How it can be useful for emergent behaviour: 80 | 81 | For emergent behaviour to be meaningful, the machines in questions must attribute the same 'meaning' to the same variable names. 82 | 83 | A `health` variable for an NPC will usually have the same meaning for two different state machines when it comes to behaviour, and for the sake of argument, let us assume two different behaviours in two different machines: 84 | 85 | 1. A machine has a `when` clause that causes the NPC to flee on low health (by controlling movement). 86 | 2. Another machine attacks on low health (controlling a bow and arrow) 87 | 88 | When both traits are present in a single machine, the NPC will potentially exhibit both behaviour simultaneously and run away while shooting, once they have low health. 89 | 90 | ### Abstract Syntax 91 | 92 | Here are some abstract syntax examples for a full pseudo-language based on this pattern. In this theoretical language, the program itself is a state machine, variables of the `MachineState` are global variables, and all of the primitives described above are part of the language itself. 93 | 94 | This is mostly pseudo-javascript with two extra `when` and `exit` keywords, and using a hypothetical decorator syntax to specify action metadata. 95 | 96 | The decorators are completely optional, and the currently proposed ones are: 97 | 98 | #### Action decorators: 99 | 100 | Action decorators may only precede a `when` block, and will only apply to that block. 101 | 102 | - `@name(action_name)` Associate a name with an action to be make it possible for inhibitors to reference it elsewhere. Can only be used once per action. 103 | 104 | - `@unless(expression)` Prevents this action from triggering if `expression` evaluates to true. Can be used multiple times with the same action. 105 | 106 | - `@inhibitedBy(action_name)` Prevents this action from triggering if another by `action_name` will execute during this tick. Can be used multiple times with the same action and different inhibitors. 107 | 108 | - `@priority(expression)` Sets a priority for the action. (Default is 0) This will influence the order of evaluation inside the main loop. Actions with lower priority values are evaluated last, while actions with higher priority values are evaluated first, meaning that they will take precedence if there's a conflict from multiple actions trying to update the same variable during the same tick. Can be a literal numeric value or an expression that returns a signed numeric value. 109 | 110 | #### Control decorators: 111 | 112 | - `@forever()` Must be defined at the very beginning of the program, and tells the state machine not to halt due to inactivity. In this case, the machine must explicitly end its execution via a call to `exit()`. Accepts no arguments. 113 | 114 | - `@input(policy?: 'once'|'always'|function, input_name?)` Implementation dependent. Defaults to `once`. Must precede a constant/readonly variable declaration. Tells `when` to poll an external value and record its value as part of the state. The interpretation of what an input is depends on the implementation. It can be a command-line argument, a memory address, or an hardware interrupt. The `policy` argument specifies how frequently the polling is done: `once` is exactly once at startup, `always` is once per `tick`. `function` is user-defined function that implements custom logic and returns a boolean. 115 | 116 | ### Examples 117 | 118 | - A prime number generator: 119 | 120 | ```typescript 121 | // maximum number of primes to brute-force before exiting, 122 | // note that this variable is a readonly external input, 123 | // and is read only once on startup. 124 | @input('once') 125 | const maxPrimes: number = 10; 126 | 127 | let counter = 2; // starting counting up from 2 128 | let current = 3; // start looking at 3 129 | let primes = []; // array to store saved primes 130 | 131 | // increment the counter with every tick till we hit the potential prime 132 | @name('increment') 133 | @unless(primes.length >= maxPrimes) 134 | when(counter < current) { 135 | counter++; 136 | } 137 | 138 | // not a prime number, reset and increment current search 139 | @name('resetNotAPrime') 140 | @unless(primes.length >= maxPrimes) 141 | when(counter < current && current % counter === 0) { 142 | counter = 2; 143 | current++; 144 | } 145 | 146 | // if this is ever triggered, then we're dealing with a prime. 147 | @name('capturePrime') 148 | @unless(primes.length >= maxPrimes) 149 | when(counter >= current) { 150 | // save the prime 151 | primes.push(current); 152 | // print it to the console 153 | console.log(current); 154 | // reset the variables and look for the next one 155 | counter = 2; 156 | current++; 157 | } 158 | ``` 159 | 160 | To make this same machine with an explicit exit clause, simply remove all `@unless` decorators and add `@forever` at the beginning. 161 | 162 | To make this machine exit, you must add the following anywhere in the file: 163 | ```js 164 | // exit when we've found enough primes 165 | @name('exitOnceDone') 166 | when(primes.length >= 10) { 167 | exit(); 168 | } 169 | ``` 170 | 171 | With either option, the predicted exit state after the machine exits should be: 172 | 173 | ```json 174 | { 175 | "counter": 2, 176 | "current": 30, 177 | "primes": [ 2, 3, 5, 7, 11, 13, 17, 19, 23, 29 ] 178 | } 179 | ``` 180 | 181 | Note: more complex examples are coming soon. -------------------------------------------------------------------------------- /src/historyManager.ts: -------------------------------------------------------------------------------- 1 | import { MachineInputSource, MachineState, StateMachine } from './index'; 2 | import { IHistory } from './interfaces'; 3 | import { InputMapping } from './util'; 4 | 5 | /** 6 | * The HistoryManager class manages the state/history of a program. 7 | */ 8 | 9 | /// ignore the internal implementation in the docs, because it exposes internal methods. 10 | /** @ignore */ 11 | export class HistoryManager = StateMachine> implements IHistory 14 | { 15 | protected readonly _instance: M; 16 | protected readonly _inputSource?: I; 17 | protected readonly _inputMappings: InputMapping[] = []; 18 | private readonly _initialState: Readonly; 19 | private _previousInputs: Partial = {}; 20 | private _maxHistory: number = Infinity; 21 | private _tick: number = 0; 22 | private _nextState: Readonly & I>; 23 | private _records: (S & Readonly)[] = []; 24 | 25 | /** 26 | * Constructor with an initial state. 27 | * @param _instance The state machine instance. 28 | * @param {S} _initialState The initial program state. 29 | * @param _inputSource Source for inputs. 30 | */ 31 | /** @ignore */ 32 | constructor( 33 | instance: M, 34 | initialState: S, 35 | inputSource?: I, 36 | inputMappings?: Set> 37 | ) 38 | { 39 | this._instance = instance; 40 | if (inputSource && inputMappings) { 41 | this._inputSource = inputSource; 42 | this._inputMappings = Array.from(inputMappings); 43 | } 44 | this._initialState = Object.assign(Object.create(null), initialState); 45 | this._nextState = Object.assign(Object.create(null), this._initialState, this._collectInputs(true)); 46 | this._nextTick(); 47 | } 48 | 49 | /** 50 | * Get the current tick number. 51 | * @returns {number} 52 | */ 53 | get tick() { 54 | return this._tick; 55 | } 56 | 57 | /** 58 | * Returns the next state being updated. 59 | * @returns {Partial} 60 | */ 61 | get nextState() { 62 | return this._nextState; 63 | } 64 | 65 | /** 66 | * Returns the entire state history. 67 | * @returns {ReadonlyArray} 68 | */ 69 | get records(): ReadonlyArray { 70 | return this._records; 71 | } 72 | 73 | /** 74 | * Return the maximum number of history states to keep. 75 | * @returns {number} 76 | */ 77 | get limit() { 78 | return this._maxHistory; 79 | } 80 | 81 | /** 82 | * Limit the number of recorded history states. 83 | */ 84 | set limit(limit: number) { 85 | if (limit < 1) limit = 1; 86 | if (limit < this._maxHistory) { 87 | // trim back the record history. 88 | this._records.splice(1, this._records.length - limit); 89 | } 90 | this._maxHistory = limit; 91 | } 92 | 93 | /** 94 | * Returns the initial state. 95 | * @returns {Partial} 96 | */ 97 | get initialState(): Readonly { 98 | return this._initialState; 99 | } 100 | 101 | /** 102 | * Returns the current state. 103 | * @returns {Partial} 104 | */ 105 | get currentState(): Readonly { 106 | return this.records[this.records.length - 1]; 107 | } 108 | 109 | /** 110 | * Returns the previous state. 111 | * @returns {Partial} 112 | */ 113 | get previousState(): Readonly { 114 | return this.records[this.records.length - 2]; 115 | } 116 | 117 | rewind(t: number, mutate?: Partial) { 118 | 119 | t = Math.min(this.tick - this._records.length, t); 120 | t = Math.max(1, t); 121 | 122 | const target = this.tick - t - 1; 123 | 124 | if (this._records[target]) { 125 | 126 | const discarded = this._records.splice(target, this._records.length - target); 127 | 128 | if (!this.currentState) { 129 | this._records.push(Object.assign(Object.create(null), this._initialState)); 130 | } 131 | 132 | if (mutate) { 133 | Object.assign(this._records[this.records.length - 1], mutate); 134 | } 135 | 136 | if (this._inputSource) { 137 | Object.assign(this._records[this.records.length - 1], this._collectInputs()); 138 | } 139 | 140 | this._tick = t; 141 | 142 | this._nextState = Object.assign(Object.create(null), this.currentState); 143 | 144 | return discarded; 145 | } 146 | 147 | return false; 148 | } 149 | 150 | /** 151 | * Clears the state history. Rewinds to the beginning, and the rest of the 152 | * current tick will be ignored. 153 | */ 154 | clear() { 155 | this.rewind(1); 156 | } 157 | 158 | /** @ignore */ 159 | _mutateTick(p: Partial) { 160 | for (let input of this._inputMappings) { 161 | if (input.key in p) delete (p as any)[input.key]; 162 | } 163 | return Object.assign(this._nextState, p); 164 | } 165 | 166 | _nextTick() { 167 | let nextState = this.nextState; 168 | if (this.tick === 0) 169 | nextState = Object.assign(nextState, this._records.pop() || {}); // discard old tick 0 170 | this._records.push(nextState as any); 171 | if (this.records.length > this._maxHistory) { 172 | this._records.splice( 173 | 0, 174 | this.records.length - this._maxHistory 175 | ); 176 | } 177 | this._beginTick(this._tick++ <= 1); 178 | } 179 | 180 | /** 181 | * 182 | * @param {boolean} patchCurrent 183 | * @private 184 | */ 185 | protected _beginTick(patchCurrent = false) { 186 | const inputs = 187 | this._inputSource ? Object.assign(Object.create(null), 188 | this._previousInputs || {}, this._collectInputs(patchCurrent)) : undefined; 189 | this._previousInputs = inputs || this._previousInputs; 190 | // patch the current state with inputs on tick 1. 191 | // We can't do this in the constructor, unfortunately. 192 | if (patchCurrent && inputs) { 193 | Object.assign(this._records[this._records.length - 1], inputs); 194 | } 195 | const sources = []; 196 | if (this.previousState) 197 | sources.push(this.previousState); 198 | if (this.currentState) 199 | sources.push(this.currentState); 200 | if (inputs) 201 | sources.push(inputs); 202 | this._nextState = Object.assign(Object.create(null), ...sources); 203 | } 204 | 205 | protected _collectInputs(force: boolean = false): Partial { 206 | if (!this._inputSource) { 207 | return {}; 208 | } 209 | const inputSource = this._inputSource!; 210 | const inputs: Partial = Object.assign(Object.create(null), this._previousInputs); 211 | for (let input of this._inputMappings) { 212 | let value: any; 213 | if (input.policy === 'always' || force) 214 | { 215 | value = inputSource[input.propertyKey as keyof I]; 216 | } 217 | else if (input.policy === 'once') { 218 | if (this.tick === 1) // only poll inputs with a `once` policy on startup 219 | { 220 | value = (inputSource as any)[input.propertyKey]; 221 | } 222 | } 223 | else { 224 | if (this.tick >= 1 && input.policy.call(null, this.currentState, this._instance)) { 225 | value = inputSource[input.propertyKey as keyof I]; 226 | } 227 | else if (input.key in this._previousInputs) { 228 | value = (this._previousInputs as any)[input.key] || this.currentState[input.key as keyof I]; 229 | } 230 | } 231 | value = input.transform ? input.transform.call(null, value) : value; 232 | if (value !== undefined) { 233 | inputs[input.key as keyof I] = value; 234 | } 235 | } 236 | return inputs; 237 | } 238 | 239 | } 240 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { actionMetadataKey, inputMetadataKey, priorityMetadataKey } from './metadataKeys'; 3 | import { ActivationCond, InputPolicy, MachineInputSource, MachineState, PriorityExpression } from './interfaces'; 4 | import { StateMachine } from './stateMachine'; 5 | import { chainWhen, ConditionBuilder, ConstructorOf, InputMapping, WhenDecoratorWithChain } from './util'; 6 | 7 | export * from './stateMachine'; 8 | export * from './interfaces'; 9 | 10 | /** 11 | * Builds a condition for the final decorator. 12 | */ 13 | 14 | // noinspection JSCommentMatchesSignature 15 | /** 16 | * A TypeScript decorator to declare a method as an action with one or more attached a conditions. 17 | * @param cond A condition to match against every tick or true. 18 | */ 19 | export function when( 20 | cond: ActivationCond | true, 21 | chainedHistory: ConditionBuilder[] = [] 22 | ): WhenDecoratorWithChain { 23 | // convenience shortcut for `@when(true)` 24 | const fixed: ActivationCond = cond === true ? () => true : cond; 25 | return chainWhen([...chainedHistory, () => fixed]); 26 | } 27 | 28 | 29 | // noinspection JSCommentMatchesSignature 30 | /** 31 | * A chainable TypeScript decorator to declare a method as an action with one or more inhibitor 32 | * conditions. 33 | * An inhibitor prevents the execution of the action for one tick if the others can activate. 34 | * @param {ActivationCond[]} inhibitor The inhibiting member action. 35 | * @return {WhenDecoratorWithChain} 36 | */ 37 | export function unless( 38 | inhibitor: ActivationCond, 39 | chainedHistory: ConditionBuilder[] = [] 40 | ): WhenDecoratorWithChain { 41 | return chainWhen([ 42 | () => function () { 43 | // @ts-ignore 44 | return !inhibitor.apply(this, arguments); 45 | }, ...chainedHistory 46 | ]); 47 | } 48 | 49 | // noinspection JSCommentMatchesSignature 50 | /** 51 | * A chainable TypeScript decorator to declare a method as an action with one or more inhibitor 52 | * actions. 53 | * An inhibitor prevents the execution of the action for one tick if the others can activate. 54 | * @param {string} inhibitorAction The name of the inhibiting member action. 55 | * @return {WhenDecoratorWithChain} 56 | */ 57 | export function inhibitedBy = any> 59 | ( 60 | inhibitorAction: keyof M, 61 | chainedHistory: ConditionBuilder[] = [] 62 | ): WhenDecoratorWithChain { 63 | const findCond = (instance: M) => { 64 | const method = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(instance), inhibitorAction); 65 | if (!method || !method.value) { 66 | throw new Error(`@inhibitedBy: could not find method ${inhibitorAction.toString()} in ${instance.constructor.name}`); 67 | } 68 | return Reflect.getMetadata(actionMetadataKey, method.value); 69 | }; 70 | return chainWhen([ 71 | (type: ConstructorOf/*, __: string | symbol, _descriptor: PropertyDescriptor*/) => { 72 | // FIXME: this could probably be done in a better way 73 | let cond: ActivationCond; 74 | return function () { 75 | // ony evaluate the activation condition at run time 76 | // @ts-ignore 77 | cond = cond || findCond(this); 78 | if (!cond) { 79 | throw new Error(`@inhibitedBy: could not find activation condition for ${inhibitorAction.toString()} in ${type.constructor.name}`); 80 | } 81 | // @ts-ignore 82 | return !cond.apply(this, arguments); 83 | }; 84 | }, 85 | ...chainedHistory 86 | ]); 87 | } 88 | 89 | /** 90 | * Mark a property as a state machine input. This will poll the target with every tick and 91 | * update the provided rename in the state. 92 | * @param policy {'once'|'always'|} 93 | * @param transform An optional transformation function to transform the value. 94 | * @param rename A new name for the variable in the state object. 95 | */ 96 | export function input = any, 98 | K extends keyof I = any, T extends I[K] = any> 99 | ( 100 | policy: InputPolicy = 'always', 101 | transform?: { (value: T): T }, 102 | rename?: K 103 | ): PropertyDecorator { 104 | return function (target: Object, propertyKey: string | symbol) { 105 | let set: Set> = Reflect.getMetadata(inputMetadataKey, target); 106 | if (!set) { 107 | set = new Set(); 108 | } 109 | set.add({ target, key: rename || propertyKey as any, propertyKey, transform, policy }); 110 | Reflect.defineMetadata(inputMetadataKey, set, target); 111 | }; 112 | } 113 | 114 | export function priority< 115 | S extends MachineState, I extends MachineInputSource = any, 116 | M extends StateMachine = any> 117 | ( 118 | priority: number| PriorityExpression, 119 | chainedHistory: ConditionBuilder[] = [] 120 | ): WhenDecoratorWithChain { 121 | function definePriority(_: ConstructorOf, __: string | symbol, descriptor: PropertyDescriptor) { 122 | Reflect.defineMetadata(priorityMetadataKey, priority, descriptor.value); 123 | } 124 | return chainWhen([definePriority, ...chainedHistory]); 125 | } 126 | 127 | export type StateObject< 128 | S extends MachineState, 129 | I extends MachineInputSource = any> = S & Readonly; -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { StateMachine } from './stateMachine'; 2 | 3 | /** 4 | * Base for user-defined States. 5 | */ 6 | export interface MachineState { 7 | } 8 | 9 | /** 10 | * Base for user-defined States. 11 | */ 12 | export interface MachineInputSource { 13 | } 14 | 15 | 16 | /** 17 | * Set this to `once` to update the input only once at startup. Set to `always` to update it with every tick, or 18 | * supply your own callback to implement a custom condition. 19 | */ 20 | export type InputPolicy> = 21 | InputPolicyCallback | 'once' | 'always'; 22 | 23 | /** 24 | * A user-defined policy for an input polling. Must return true for the input to be polled on that specific `tick`. 25 | */ 26 | export type InputPolicyCallback> = 27 | { 28 | (state: Readonly, m: M): boolean 29 | }; 30 | 31 | 32 | /** 33 | * An activation condition, takes two arguments and must return true for the associated action to fire. 34 | */ 35 | export type ActivationCond = 36 | (state: Readonly, machine: StateMachine) => boolean; 37 | 38 | /** 39 | * An activation action, takes two arguments and will only be executed during a tick 40 | * when the associated condition returns true. 41 | */ 42 | export type ActivationAction = 43 | (state: Readonly, machine: StateMachine) 44 | => Pick | void; 45 | 46 | /** 47 | * An activation condition, takes two arguments and must return true for the associated action to fire. 48 | */ 49 | export type PriorityExpression = 50 | (state: Readonly, machine: StateMachine) => number; 51 | 52 | /** 53 | * The HistoryManager interface allows for state manipulation and the rewinding of a program. 54 | */ 55 | export interface IHistory { 56 | readonly tick: number; 57 | readonly records: ReadonlyArray>; 58 | readonly currentState: Readonly; 59 | readonly initialState: Readonly; 60 | readonly nextState: Readonly & I>; 61 | 62 | /** 63 | * Limit the maximum number of past history states kept on record. 64 | */ 65 | limit: number; 66 | 67 | /** 68 | * Rewind time to `t`, the rest of the currently executing tick will be ignored. 69 | * A partial state can be passed as the second argument to mutate the rewound state and take back information to 70 | * the past state. 71 | * @param {number} t The discrete tick in time to rewind to. 72 | * @param {Partial} mutate Any mutations to apply to the state after rewinding. 73 | */ 74 | rewind(t: number, mutate?: Partial): void; 75 | 76 | /** 77 | * Clears the state history. Rewinds to the beginning, and the rest of the current tick will be aborted. 78 | */ 79 | clear(): void; 80 | } 81 | -------------------------------------------------------------------------------- /src/metadataKeys.ts: -------------------------------------------------------------------------------- 1 | /** @ignore */ 2 | export const actionMetadataKey = Symbol('when-action'); 3 | /** @ignore */ 4 | export const inputMetadataKey = Symbol('when-input'); 5 | /** @ignore */ 6 | export const priorityMetadataKey = Symbol('when-priority'); 7 | -------------------------------------------------------------------------------- /src/stateMachine.ts: -------------------------------------------------------------------------------- 1 | import { HistoryManager } from './historyManager'; 2 | import { ActivationAction, ActivationCond, MachineInputSource, MachineState, PriorityExpression } from './index'; 3 | import { IHistory } from './interfaces'; 4 | import { actionMetadataKey, inputMetadataKey, priorityMetadataKey } from './metadataKeys'; 5 | import { getAllMethods, InputOf, StateOf } from './util'; 6 | 7 | export type StateCombiner, 8 | M2 extends StateMachine, 9 | S1 extends MachineState = StateOf, 10 | S2 extends MachineState = StateOf, 11 | I1 extends MachineState = InputOf, 12 | I2 extends MachineState = InputOf> = 13 | { 14 | (params: { 15 | first: M1, 16 | second: M2, 17 | precedence: 'first' | 'second' 18 | } 19 | ): (S1 & S2) 20 | }; 21 | 22 | export type ProgramEntry = 23 | { 24 | action: ActivationAction; 25 | priority: number | PriorityExpression; 26 | } 27 | 28 | /** 29 | * Your state machine should inherit the `StateMachine` class. 30 | */ 31 | export class StateMachine { 32 | /** 33 | * The active state machine program. 34 | * @type {Map} 35 | * @private 36 | */ 37 | private _program: Map, ProgramEntry> = new Map(); 38 | private readonly _history: HistoryManager; 39 | private _exitState?: Readonly; 40 | 41 | /** 42 | * Constructor, requires an initial state. 43 | * @param {S} initialState The initial state for this machine. 44 | * @param inputSource Machine inputs. 45 | */ 46 | protected constructor(initialState: S, inputSource?: I) { 47 | const properties: ActivationAction[] = getAllMethods(this) as any; 48 | for (let action of properties) { 49 | if (Reflect.hasMetadata(actionMetadataKey, action)) { 50 | const cond = Reflect.getMetadata(actionMetadataKey, action); 51 | const priority = Reflect.getMetadata(priorityMetadataKey, action); 52 | this._program.set(cond, { priority, action }); 53 | } 54 | } 55 | this._history = new HistoryManager(this, initialState, inputSource, 56 | inputSource ? Reflect.getMetadata(inputMetadataKey, inputSource) : [] 57 | ); 58 | } 59 | 60 | /** 61 | * The state at program exit. Returns `undefined` unless the program has ended. 62 | * @returns {Readonly | undefined} 63 | */ 64 | get exitState() { 65 | return this._exitState; 66 | } 67 | 68 | get history(): IHistory { 69 | return this._history; 70 | } 71 | 72 | /** 73 | * Advance a single tick and return. 74 | * @returns {number} Number of actions fired during this tick. 75 | */ 76 | step() { 77 | let fired = 0; 78 | if (this.history.tick < 1) { 79 | this._history._nextTick(); 80 | } 81 | const currentTick = this.history.tick; 82 | const currentState = this.history.currentState; 83 | // let actions: [ActivationCond, ProgramEntry][] = []; 84 | // for(let [key, entry] of this._program) { 85 | // const priority = (typeof entry.priority === 'number' ? 86 | // entry.priority : entry.priority(currentState, this)) || 0; 87 | // actions.splice(priority, 0, [key, entry]); 88 | // } 89 | let actions = 90 | Array.from(this._program.entries()) 91 | .map(([cond, entry]) => 92 | ({ 93 | cond, 94 | entry: { 95 | action: entry.action, 96 | priority: (typeof entry.priority === 'function' ? 97 | entry.priority(currentState, this) : entry.priority) || 0 98 | } 99 | }) 100 | ) 101 | .sort(({ entry: { priority: p1 } }, { entry: { priority: p2 } }) => p1 - p2); 102 | for (let { cond, entry: { action } } of actions) { 103 | if (this.history.tick !== currentTick && !this.exitState) { 104 | // abort current tick on rewind. 105 | // always report at least 1 action fired in this case. 106 | return Math.max(1, fired); 107 | } 108 | if (this.exitState) { 109 | break; 110 | } 111 | if (cond.call(this, this.history.currentState, this)) { 112 | const newState = action.call(this, this.history.currentState, this); 113 | if (newState) { 114 | this._history._mutateTick(newState); 115 | } 116 | fired++; 117 | } 118 | } 119 | this._history._nextTick(); 120 | if (fired === 0) { 121 | this._exitState = this.history.currentState as any; 122 | } 123 | return fired; 124 | } 125 | 126 | /** 127 | * A blocking call that evaluates the state machine until it exits. 128 | * @param {boolean} forever Should we keep going even if the machine stops reacting? 129 | * @returns {Readonly|null} Returns the machine's exit state, 130 | * or null if the machine halted. 131 | */ 132 | run(forever: boolean = false): Readonly { 133 | while (!this._exitState) { 134 | const change = this.step(); 135 | if (!forever && !change) { 136 | break; 137 | } 138 | } 139 | return this._exitState || this._history.currentState; 140 | } 141 | 142 | /** 143 | * Resets the state machine to the initial state. 144 | * @param {S} initialState (optional) Restart with a different initial state. 145 | */ 146 | reset(initialState: S = this.history.initialState) { 147 | this._exitState = undefined; 148 | this.history.rewind(Infinity, initialState); 149 | } 150 | 151 | 152 | /** 153 | * Call this from any action to signal program completion. 154 | * @param {Readonly} exitState The exit state to 155 | * return from `.run.` 156 | */ 157 | exit(exitState?: Readonly) { 158 | if (exitState) { 159 | this._exitState = Object.assign(Object.create(null), this.history.currentState, exitState); 160 | } 161 | else { 162 | this._exitState = this.history.currentState as any; 163 | } 164 | } 165 | 166 | /** 167 | * Combine this machine with a new one. (warning: shared variables in state 168 | * may cause emergent behaviour, calls to `exit()` from one machine may abort 169 | * early for the other) 170 | * @param other Other machine to combine with. 171 | * @param precedence Which machine takes precedence when there's a conflict in 172 | * state variables. Defaults to 'this'. 173 | * @param initialState A combined state to use for the new machine, or a 174 | * custom function to combine the states. You may supply a string 175 | * {'current'|'initial'} to perform automatic conversion. 176 | * Defaults to 'current'. 177 | * @return A hybrid event machine exhibiting the behaviour of both parents. 178 | */ 179 | // FIXME: write more comprehensive tests, but for now recombination is 180 | // not part of the core functionality 181 | /* istanbul ignore next */ 182 | recombine, OS extends MachineState = StateOf>( 183 | other: T, 184 | precedence: 'this' | 'other' = 'this', 185 | initialState: (OS & S) | 186 | StateCombiner, StateMachine> | 187 | 'current' | 'initial' = 'initial' 188 | ) 189 | { 190 | const state = typeof initialState === 'function' ? 191 | initialState({ 192 | first: this, 193 | second: other, 194 | precedence: precedence === 'this' ? 'first' : 'second' 195 | }) : (typeof initialState === 'string' ? Object.assign( 196 | Object.create(null), 197 | initialState === 'current' ? 198 | (precedence === 'this' ? other 199 | : this).history.currentState 200 | : 201 | (precedence === 'this' ? other 202 | : this).history.initialState, 203 | initialState === 'current' ? 204 | (precedence === 'this' ? this 205 | : other).history.currentState 206 | : 207 | (precedence === 'this' ? this 208 | : other).history.initialState 209 | ) 210 | : initialState); 211 | const child = new StateMachine(state); 212 | const program = child._program = new Map(); 213 | for (let [cond, action] of other._program) { 214 | program.set(cond as any, action as any); 215 | } 216 | for (let [cond, action] of this._program) { 217 | program.set(cond as any, action as any); 218 | } 219 | return child; 220 | } 221 | 222 | } 223 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { actionMetadataKey, inputMetadataKey } from './metadataKeys'; 2 | import { 3 | inhibitedBy, 4 | InputPolicy, 5 | MachineInputSource, 6 | priority, 7 | PriorityExpression, 8 | StateMachine, 9 | unless, 10 | when 11 | } from './index'; 12 | import { ActivationCond } from './interfaces'; 13 | 14 | /** @ignore */ 15 | export type MemberOf = { 16 | (this: T, ...args: any[]): any; 17 | }; 18 | 19 | /** @ignore */ 20 | export type InputMapping = { 21 | target: any; 22 | key: K; 23 | propertyKey: string|symbol; 24 | policy: InputPolicy; 25 | transform?: { (value: T): T }; 26 | } 27 | 28 | /** 29 | * Unused for now, can be used by @inhibitWhen to lookup 30 | * inhibitor actions in the parent class(es) later on. 31 | */ 32 | /** @ignore */ 33 | // istanbul ignore next 34 | export function getInheritanceTree(entity: ConstructorOf): Function[] { 35 | const tree: Function[] = [entity as any]; 36 | const getPrototypeOf = (object: Function): void => { 37 | const proto = Object.getPrototypeOf(object); 38 | if (proto && proto.name) { 39 | tree.push(proto); 40 | getPrototypeOf(proto); 41 | } 42 | }; 43 | getPrototypeOf(entity as any); 44 | return tree; 45 | } 46 | 47 | /** @ignore */ 48 | export type ConditionBuilder = { 49 | (T: any, methodName: string | symbol, descriptor: PropertyDescriptor): ActivationCond | void 50 | } 51 | 52 | export type WhenDecoratorChainResult = { 53 | andWhen(cond: ActivationCond | true): WhenDecoratorWithChain; 54 | unless(condition: ActivationCond): WhenDecoratorWithChain; 55 | inhibitedBy(inhibitor: keyof M): WhenDecoratorWithChain; 56 | priority(p: number|PriorityExpression): WhenDecoratorWithChain; 57 | } 58 | export type WhenDecoratorWithChain = MethodDecorator & WhenDecoratorChainResult; 59 | 60 | /** @ignore */ 61 | export function chainWhen(chainedHistory: ConditionBuilder[]): WhenDecoratorWithChain 62 | { 63 | return Object.assign( 64 | buildDecorator(chainedHistory), 65 | { 66 | andWhen: (...args: any[]) => (when as any)(...args, chainedHistory), 67 | unless: (...args: any[]) => (unless as any)(...args, chainedHistory), 68 | inhibitedBy: (...args: any[]) => (inhibitedBy as any)(...args, chainedHistory), 69 | priority: (...args: any[]) => (priority as any)(...args, chainedHistory), 70 | } 71 | ); 72 | } 73 | 74 | /** 75 | * Build a decorator out of a list of conditions. 76 | * @param {ActivationCond[]} builders 77 | * @param {boolean} invert 78 | * @return {(_: any, _methodName: (string | symbol), descriptor: PropertyDescriptor) => void} 79 | */ 80 | /** @ignore */ 81 | function buildDecorator(builders: ConditionBuilder[]) { 82 | return function decorator(Type: any, methodName: string | symbol, descriptor: PropertyDescriptor) 83 | { 84 | const built = builders.map(builder => builder(Type, methodName, descriptor)) 85 | .filter(cond => typeof cond === 'function'); 86 | const cond = built.length > 1 ? function () { 87 | for (let current of built) { 88 | // tell TS to ignore the next line because we specifically want a non-contextual `this` 89 | // here and it's not worth sacrificing the overall strictness of the entire build. 90 | // @ts-ignore 91 | if (!current.apply(this, arguments)) 92 | return false; 93 | } 94 | return true; 95 | } : built.pop(); 96 | Reflect.defineMetadata(actionMetadataKey, cond, descriptor.value); 97 | }; 98 | } 99 | 100 | /** @ignore */ 101 | export type ConstructorOf = T extends { 102 | new(...args: any[]): infer T 103 | } ? T : never; 104 | 105 | /** @ignore */ 106 | export function getAllMethods(object: any): Function[] { 107 | let current = object; 108 | let props: string[] = []; 109 | 110 | do { 111 | let propertyNames = Object.getOwnPropertyNames(current); 112 | if (Reflect.hasMetadata(inputMetadataKey, current)) { 113 | const inputs: InputMapping[] = Array.from(Reflect.getMetadata(inputMetadataKey, current)); 114 | propertyNames = propertyNames.filter(k => !inputs.find(inp => inp.propertyKey === k)); 115 | } 116 | props.push(...propertyNames); 117 | current = Object.getPrototypeOf(current); 118 | } while (current); 119 | 120 | return Array.from( 121 | new Set(props.map(p => 122 | typeof object[p] === 'function' ? object[p] : null) 123 | .filter(p => p !== null))); 124 | } 125 | 126 | export type StateOf> = 127 | M extends StateMachine ? S : never; 128 | 129 | export type InputOf> = 130 | M extends StateMachine ? I : never; -------------------------------------------------------------------------------- /tests/prime.test.ts: -------------------------------------------------------------------------------- 1 | import { MachineState, StateMachine, when } from '../src'; 2 | 3 | describe('Prime', () => { 4 | it('Can calculate a prime', () => { 5 | 6 | interface PrimeState extends MachineState { 7 | counter: number; 8 | current: number; 9 | primes: number[]; 10 | } 11 | 12 | class PrimeMachine extends StateMachine { 13 | constructor() { 14 | super({ counter: 2, current: 3, primes: [2] }); 15 | } 16 | 17 | @when(state => state.counter < state.current) 18 | incrementCounterOncePerTick({ counter }: PrimeState) { 19 | return { counter: counter + 1 }; 20 | } 21 | 22 | @when(state => state.counter < state.current && state.current % state.counter === 0) 23 | resetNotPrime({ current }: PrimeState) { 24 | return { counter: 2, current: current + 1 }; 25 | } 26 | 27 | @when(state => state.counter >= state.current) 28 | capturePrime({ primes, current }: PrimeState) { 29 | return { counter: 2, current: current + 1, primes: [...primes, current] }; 30 | } 31 | 32 | @when(state => state.primes.length >= 10) 33 | exitMachine() { 34 | this.exit({ counter: 0, current: 0, primes: this.history.nextState.primes! }); 35 | } 36 | } 37 | 38 | const primeMachine = new PrimeMachine(); 39 | 40 | const result = primeMachine.run(); 41 | 42 | expect(result).not.toBeFalsy(); 43 | expect(result!.primes).toEqual([2, 3, 5, 7, 11, 13, 17, 19, 23, 29]); 44 | 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /tests/recombination.test.ts: -------------------------------------------------------------------------------- 1 | import { MachineState, StateMachine, when } from '../src'; 2 | 3 | describe('Recombination', () => { 4 | 5 | interface CommonState extends MachineState { 6 | sharedValue?: string; 7 | } 8 | 9 | interface StateA extends CommonState { 10 | valueA: string; 11 | } 12 | 13 | interface StateB extends CommonState { 14 | valueB: string; 15 | } 16 | 17 | class TestMachineA extends StateMachine { 18 | constructor() { 19 | super({ valueA: '' }); 20 | } 21 | 22 | @when((_, m) => m.history.tick <= 5) 23 | incrementAOncePerTick(s: StateA) { 24 | return { valueA: s.valueA + (s.sharedValue || 'a') }; 25 | } 26 | 27 | } 28 | 29 | class TestMachineB extends StateMachine { 30 | constructor() { 31 | super({ valueB: '' }); 32 | } 33 | 34 | @when((_, m) => m.history.tick <= 10) 35 | incrementBOncePerTick(s: StateB) { 36 | return { valueB: s.valueB + (s.sharedValue || 'b') }; 37 | } 38 | 39 | } 40 | 41 | class TestMachineC extends StateMachine> { 42 | constructor() { 43 | super({ sharedValue: 'c' }); 44 | } 45 | 46 | @when((_, m) => m.history.tick <= 10) 47 | incrementSharedPerTick(s: Required) { 48 | return { sharedValue: s.sharedValue === 'c' ? 'd' : 'c' }; 49 | } 50 | } 51 | 52 | it('Can handle basic recombination', () => { 53 | const testA = new TestMachineA(); 54 | const testB = new TestMachineB(); 55 | const testC = testA.recombine(testB); 56 | expect(testC).toBeInstanceOf(StateMachine); 57 | const resultC = testC.run(); 58 | expect(resultC).toEqual({ valueA: 'a'.repeat(5), valueB: 'b'.repeat(10) }); 59 | }); 60 | 61 | it('Recombination can introduce new behaviour', () => { 62 | const testA = new TestMachineA(); 63 | const testB = new TestMachineB(); 64 | 65 | const testAll = testA.recombine(testB).recombine(new TestMachineC()); 66 | 67 | const resultA = testA.run(); 68 | const resultB = testB.run(); 69 | 70 | expect(resultA).toEqual({ valueA: 'a'.repeat(5) }); 71 | expect(resultB).toEqual({ valueB: 'b'.repeat(10) }); 72 | 73 | const resultAll = testAll.run(); 74 | 75 | expect(resultAll).toEqual({ sharedValue: 'c', valueA: 'cdcdc', valueB: 'cdcdcdcdcd' }); 76 | }); 77 | 78 | }); 79 | -------------------------------------------------------------------------------- /tests/stateMachine.test.ts: -------------------------------------------------------------------------------- 1 | import { input, MachineInputSource, StateMachine, StateObject, unless, when } from '../src'; 2 | 3 | describe('StateMachine', () => { 4 | 5 | it('Can handle a basic state machine', () => { 6 | 7 | type State = { 8 | value: number; 9 | } 10 | 11 | class TestMachine extends StateMachine { 12 | constructor() { 13 | super({ value: 0 }); 14 | } 15 | 16 | @when(state => state.value >= 5) 17 | exitWhenDone(_: State, m: TestMachine) { 18 | // this should execute on tick 6 19 | expect(m.history.tick).toEqual(6); 20 | m.exit(); 21 | } 22 | 23 | @when(state => state.value < 5) 24 | incrementOncePerTick(s: State) { 25 | return { value: s.value + 1 }; 26 | } 27 | 28 | } 29 | 30 | const test = new TestMachine(); 31 | const result = test.run(); 32 | 33 | expect(result).toEqual({ value: 5 }); 34 | }); 35 | 36 | it('Has functioning rewind with side-effects', () => { 37 | 38 | type State = { 39 | value: number; 40 | cycle: number; 41 | } 42 | 43 | class TestMachine extends StateMachine { 44 | constructor() { 45 | super({ value: 0, cycle: 0 }); 46 | } 47 | 48 | @when(s => s.value < 5) 49 | incrementOncePerTick(s: State) { 50 | return { value: s.value + 1 }; 51 | } 52 | 53 | @unless(s => s.value < 5) 54 | exitWhenDone(s: State, m: TestMachine) { 55 | if (s.cycle < 10) { // rewind the program 10 times 56 | // rewind the state machine with a side-effect 57 | m.history.rewind(Infinity, { cycle: s.cycle + 1 }); 58 | } 59 | else { 60 | // exit the state machine with the currently saved state 61 | // note that any state mutations applied within this tick 62 | // will be ignored! 63 | m.exit(); 64 | // // you can mitigate this behaviour by using: 65 | // m.exit(m.history.nextState as State); 66 | } 67 | } 68 | } 69 | 70 | const test = new TestMachine(); 71 | const result = test.run(true); 72 | 73 | // expected: the state machine will exit with the last *saved* state, with value equal to 5. 74 | expect(result).toEqual({ value: 5, cycle: 10 }); 75 | 76 | }); 77 | 78 | it('Can restart with a new state', () => { 79 | 80 | type State = { 81 | value: number; 82 | } 83 | 84 | class TestMachine extends StateMachine { 85 | constructor() { 86 | super({ value: 0 }); 87 | } 88 | 89 | @when(s => s.value < 5) 90 | incrementOncePerTick(s: State) { 91 | return { value: s.value + 1 }; 92 | } 93 | 94 | @when(state => state.value >= 5) 95 | exitWhenDone(s: State, m: TestMachine) { 96 | if (s.value >= 100) { 97 | m.exit(); 98 | } 99 | else { 100 | m.reset({ value: 100 }); 101 | } 102 | } 103 | } 104 | 105 | const test = new TestMachine(); 106 | const result = test.run(); 107 | 108 | expect(result).toEqual({ value: 100 }); 109 | 110 | }); 111 | 112 | 113 | it('Can handle resets', () => { 114 | 115 | type State = { 116 | value: number | null; 117 | } 118 | 119 | let rewinds = 0; 120 | 121 | class TestMachine extends StateMachine { 122 | constructor() { 123 | super({ value: 0 }); 124 | } 125 | 126 | @when(true) 127 | incrementOncePerTick(s: State) { 128 | return { value: s.value! + 1 }; 129 | } 130 | 131 | @when(state => state.value !== null && state.value >= 5) 132 | exitWhenDone(_: State, m: TestMachine) { 133 | // never do this in reality, never reference anything other than the state! 134 | if (rewinds++ > 100) { 135 | m.exit({ value: null }); 136 | } 137 | m.history.clear(); 138 | rewinds++; 139 | } 140 | } 141 | 142 | const test = new TestMachine(); 143 | const result = test.run(); 144 | 145 | expect(result).toEqual({ value: null }); 146 | 147 | }); 148 | 149 | it('Can rewind n times', () => { 150 | 151 | type State = { 152 | value: number; 153 | } 154 | 155 | let rewinds = 0; 156 | let series: [number, number][] = []; 157 | 158 | class TestMachine extends StateMachine { 159 | constructor() { 160 | super({ value: 0 }); 161 | } 162 | 163 | @when(true) 164 | incrementOncePerTick(s: State, m: TestMachine) { 165 | // never do this in reality, never reference or modify anything other than the state! 166 | series.push([s.value, m.history.tick]); 167 | return { value: s.value + 1 }; 168 | } 169 | 170 | @when((_, machine) => machine.history.tick === 4) 171 | exitWhenDone(_: State, m: TestMachine) { 172 | // never do this in reality, never reference or modify anything other than the state! 173 | if (++rewinds >= 2) 174 | { 175 | m.exit(); 176 | return; 177 | } 178 | m.history.rewind(2); 179 | } 180 | } 181 | 182 | const test = new TestMachine(); 183 | const result = test.run(true); 184 | 185 | expect(result).toEqual({ value: 3 }); 186 | expect(series).toEqual([[0, 1], [1, 2], [2, 3], [3, 4], [1, 2], [2, 3], [3, 4]]); 187 | 188 | }); 189 | 190 | it('Can discard old states', () => { 191 | 192 | type State = { 193 | value: number; 194 | } 195 | 196 | let historyLength = 4; 197 | 198 | class TestMachine extends StateMachine { 199 | constructor() { 200 | super({ value: 0 }); 201 | this.history.limit = historyLength; 202 | } 203 | 204 | @when(true) 205 | incrementOncePerTick(s: State) { 206 | return { value: s.value + 1 }; 207 | } 208 | } 209 | 210 | const m = new TestMachine(); 211 | 212 | while (m.history.tick < 5) { 213 | expect(m.history.records.length).toBeLessThanOrEqual(historyLength); 214 | const expected: State[] = []; 215 | let i = Math.min(m.history.limit, m.history.tick); 216 | let v = Math.max(m.history.tick - m.history.limit, 0); 217 | while (i-- > 0) { 218 | expected.push({ value: v++ }); 219 | } 220 | expect(m.history.records).toEqual(expected); 221 | m.step(); 222 | } 223 | 224 | }); 225 | 226 | it('Can run with history disabled', () => { 227 | 228 | type State = { 229 | value: number; 230 | } 231 | 232 | 233 | class TestMachine extends StateMachine { 234 | constructor() { 235 | super({ value: 0 }); 236 | this.history.limit = 0; 237 | } 238 | 239 | @when(true) 240 | incrementOncePerTick(s: State) { 241 | return { value: s.value + 1 }; 242 | } 243 | 244 | @when((_, m) => m.history.tick >= 5) 245 | exitOnTick(_: State, m: TestMachine) { 246 | m.exit(); 247 | } 248 | 249 | } 250 | 251 | const m = new TestMachine(); 252 | 253 | while (!m.exitState) { 254 | m.step(); 255 | expect(m.history.records).toHaveLength(1); 256 | } 257 | 258 | expect(m.exitState).toEqual({ value: 4 }); 259 | 260 | }); 261 | 262 | it('Can reset', () => { 263 | 264 | type State = { 265 | inc: number; 266 | to: number; 267 | count: number 268 | } 269 | 270 | class TestMachine extends StateMachine { 271 | constructor() { 272 | super({ inc: 1, to: 10, count: 0 }); 273 | this.history.limit = 0; 274 | } 275 | 276 | @when(s => s.count < s.to) 277 | incrementOnceTillEqual(s: State) { 278 | return { count: s.count + s.inc }; 279 | } 280 | 281 | @when(s => s.count >= s.to) 282 | exitOnEqual(_: State, m: TestMachine) { 283 | m.exit(); 284 | } 285 | } 286 | 287 | const m = new TestMachine(); 288 | 289 | while (m.history.tick < 3) { 290 | m.step(); 291 | } 292 | 293 | expect(m.history.currentState).toEqual({ inc: 1, to: 10, count: 2 }); 294 | 295 | m.reset({ inc: 2, to: 8, count: 0 }); 296 | 297 | expect(m.run()).toEqual({ inc: 2, to: 8, count: 8 }); 298 | 299 | m.reset({ inc: 1, to: 10, count: 0 }); 300 | expect(m.run()).toEqual({ inc: 1, to: 10, count: 10 }); 301 | 302 | m.reset({ inc: 1, to: 10, count: 0 }); 303 | expect(m.run()).toEqual({ inc: 1, to: 10, count: 10 }); 304 | 305 | }); 306 | 307 | 308 | it('@andWhen works', () => { 309 | 310 | type State = { 311 | count: number; 312 | inc: number; 313 | to: number; 314 | } 315 | 316 | class TestMachine extends StateMachine { 317 | constructor() { 318 | super({ inc: 1, to: 10, count: 0 }); 319 | this.history.limit = 0; 320 | } 321 | 322 | @when(s => s.count < s.to) 323 | keepMe() { 324 | /// empty rule to make the machine run to its conclusion 325 | } 326 | 327 | @when(s => s.count < s.to) 328 | .andWhen((_s, m) => m.history.tick % 2 === 0) 329 | incrementOnceTillEqual(s: State) { 330 | return { count: s.count + s.inc }; 331 | } 332 | 333 | @when(s => s.count >= s.to) 334 | exitOnEqual(_: State, m: TestMachine) { 335 | m.exit(); 336 | } 337 | } 338 | 339 | const m = new TestMachine(); 340 | 341 | expect(m.run()).toEqual({ inc: 1, to: 10, count: 10 }); 342 | expect(m.history.tick).toEqual(22); 343 | 344 | }); 345 | 346 | it('@unless works', () => { 347 | 348 | type State = { 349 | count: number; 350 | inc: number; 351 | to: number; 352 | } 353 | 354 | class TestMachine extends StateMachine { 355 | constructor() { 356 | super({ inc: 1, to: 10, count: 0 }); 357 | this.history.limit = 0; 358 | } 359 | 360 | @when(true).unless(s => s.count >= s.to) 361 | incrementOnceTillEqual(s: State) { 362 | return { count: s.count + s.inc }; 363 | } 364 | 365 | @when(s => s.count >= s.to) 366 | exitOnEqual(_: State, m: TestMachine) { 367 | m.exit(); 368 | } 369 | } 370 | 371 | const m = new TestMachine(); 372 | 373 | while (m.history.tick < 3) { 374 | m.step(); 375 | } 376 | 377 | expect(m.history.currentState).toEqual({ inc: 1, to: 10, count: 2 }); 378 | 379 | m.reset({ inc: 2, to: 8, count: 0 }); 380 | 381 | expect(m.run()).toEqual({ inc: 2, to: 8, count: 8 }); 382 | 383 | m.reset({ inc: 1, to: 10, count: 0 }); 384 | expect(m.run()).toEqual({ inc: 1, to: 10, count: 10 }); 385 | 386 | m.reset({ inc: 1, to: 10, count: 0 }); 387 | expect(m.run()).toEqual({ inc: 1, to: 10, count: 10 }); 388 | 389 | }); 390 | 391 | it('@inhibitedBy works', () => { 392 | 393 | type State = { 394 | count: number; 395 | inc: number; 396 | to: number; 397 | } 398 | 399 | class TestMachine extends StateMachine { 400 | constructor() { 401 | super({ inc: 1, to: 10, count: 0 }); 402 | this.history.limit = 0; 403 | } 404 | 405 | @when(s => s.count < s.to) 406 | .inhibitedBy('runsEveryOtherTime') 407 | incrementOnceTillEqual(s: State) { 408 | return { count: s.count + s.inc }; 409 | } 410 | 411 | @when((_s, m) => m.history.tick % 4 === 0) 412 | runsEveryOtherTime(s: State, _m: TestMachine) { 413 | return { count: s.count - s.inc }; 414 | } 415 | 416 | @when(s => s.count >= s.to) 417 | exitOnEqual(_: State, m: TestMachine) { 418 | m.exit(); 419 | } 420 | } 421 | 422 | const m = new TestMachine(); 423 | 424 | expect(m.run()).toEqual({ inc: 1, to: 10, count: 10 }); 425 | expect(m.history.tick).toEqual(20); 426 | 427 | }); 428 | 429 | it('@input works', () => { 430 | 431 | interface IFactorialInputs extends MachineInputSource { 432 | readonly externalCounter: number; 433 | } 434 | 435 | class FactorialInputs implements IFactorialInputs { 436 | internalCounter: number = 0; 437 | 438 | // polled with every tick. 439 | @input('always') 440 | get externalCounter() { 441 | return this.internalCounter; 442 | } 443 | 444 | update() { this.internalCounter++; } 445 | } 446 | 447 | type FactorialState = { 448 | currentValue: number; 449 | } 450 | 451 | class FactorialMachine extends StateMachine { 452 | 453 | /// define an external counter, this can be the last known value for a 454 | // real-time signal, the state of an external system, the health of an 455 | // NPC, or anything not computationally expensive that can be 456 | constructor(inputSource: IFactorialInputs) { 457 | /* we set this external input here to satisfy TypeScript, 458 | * but it will be overwritten anyway */ 459 | super({ currentValue: 1 }, inputSource); 460 | } 461 | 462 | @when((_, machine) => machine.history.tick <= 10) 463 | tryToOverwriteInput() { 464 | return { externalCounter: null }; 465 | } 466 | 467 | @when(state => state.externalCounter <= 5 && state.externalCounter > 0) 468 | incrementalFactorial(s: StateObject) { 469 | return { currentValue: s.currentValue * s.externalCounter }; 470 | } 471 | 472 | } 473 | 474 | const inputSource = new FactorialInputs(); 475 | 476 | const test = new FactorialMachine(inputSource); 477 | 478 | do { 479 | inputSource.update(); 480 | } while (test.step()); 481 | 482 | 483 | expect(test.exitState).toBeTruthy(); 484 | expect(test.exitState).toHaveProperty('externalCounter', inputSource.internalCounter - 1); 485 | expect(test.exitState).toHaveProperty('currentValue', 120); 486 | }); 487 | 488 | it('@input policies work', () => { 489 | 490 | interface IBlankMachineInputs { 491 | fixed: number; 492 | increments: number; 493 | random: number; 494 | } 495 | 496 | type BlankState = { 497 | tick: number; 498 | }; 499 | 500 | class BlankMachine extends StateMachine { 501 | 502 | constructor(inputSource: IBlankMachineInputs) { 503 | super({ tick: 0 }, inputSource); 504 | } 505 | 506 | @when(true) 507 | keepMe(_: any, m: BlankMachine) { 508 | return { tick: m.history.tick }; 509 | } 510 | 511 | @when((_, m) => m.history.tick > 5) 512 | exitMachine(_: any, m: BlankMachine) { 513 | m.exit(); 514 | } 515 | } 516 | 517 | class BlankMachineInputs implements IBlankMachineInputs { 518 | 519 | private _fixed = 10; 520 | private _increments = 0; 521 | private _random = 0; 522 | 523 | // polled once at startup. 524 | @input('once') 525 | get fixed() { 526 | return this._fixed; 527 | } 528 | 529 | // polled every other time and once at the beginning 530 | @input( 531 | (_, m) => m.history.tick % 2 === 0 532 | ) 533 | get increments() { 534 | return this._increments; 535 | } 536 | 537 | // polled with every tick. 538 | @input('always') 539 | get random() { 540 | return this._random; 541 | } 542 | 543 | snapshot(tick: number) { 544 | return { 545 | tick, 546 | fixed: this._fixed, 547 | increments: this._increments, 548 | random: this._random 549 | }; 550 | } 551 | 552 | seed() { 553 | this._random = Math.round(Math.random() * 1000); 554 | } 555 | 556 | update(tick: number): StateObject { 557 | let old = this.snapshot(tick); 558 | this._fixed = 10; 559 | this._increments++; 560 | this._random = Math.round(Math.random() * 1000); 561 | if (old.tick % 2 !== 0) old.increments = old.increments === 0 ? 0 : old.increments - 1; 562 | return { ...old, tick }; 563 | } 564 | } 565 | 566 | const inputSource = new BlankMachineInputs(); 567 | const expectedHistory: StateObject[] = []; 568 | const test = new BlankMachine(inputSource); 569 | 570 | expectedHistory.push(inputSource.snapshot(0)); // initial state 571 | 572 | inputSource.seed(); 573 | 574 | expectedHistory.push(inputSource.snapshot(1)); 575 | 576 | while (test.step()) { 577 | expectedHistory.push(inputSource.update(test.history.tick)); 578 | } 579 | 580 | // On the last tick, the handler won't fire and update the tick. 581 | expectedHistory[expectedHistory.length -1].tick--; 582 | 583 | expect(test.history.records).toEqual(expectedHistory); 584 | }); 585 | 586 | it('Can handle priorities', () => { 587 | 588 | type State = { 589 | value: number; 590 | } 591 | 592 | class TestMachine extends StateMachine { 593 | constructor() { 594 | super({ value: 0 }); 595 | } 596 | 597 | @when(state => Math.abs(state.value) >= 5) 598 | exitWhenDone(_: State, m: TestMachine) { 599 | // this should execute on tick 6 600 | expect(m.history.tick).toEqual(6); 601 | m.exit(); 602 | } 603 | 604 | 605 | // overrides the increment action via priority 606 | @when(state => state.value > -5).priority(10) 607 | decrementOncePerTick(s: State) { 608 | return { value: s.value - 1 }; 609 | } 610 | 611 | @when(state => state.value < 5).priority(-10) 612 | incrementOncePerTick(s: State) { 613 | return { value: s.value + 1 }; 614 | } 615 | 616 | @when((_, m) => m.history.tick > 4) 617 | .priority(state => state.value % 2 === 0 ? 1000 : 0) 618 | mul(s: State) { 619 | return { value: s.value * 2 }; 620 | } 621 | 622 | } 623 | 624 | const test = new TestMachine(); 625 | const result = test.run(); 626 | 627 | expect(result).toEqual({ value: -8 }); 628 | }); 629 | 630 | 631 | }); 632 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es2015", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | "lib": ["es2015", "es2016", "es2017", "esnext.asynciterable"], /* Specify library files to be included in the compilation. */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | "declarationDir": "dist/types", 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "dist/lib", /* Redirect output structure to the directory. */ 16 | // "rootDir": "src/", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | // "removeComments": true, /* Do not emit comments to output. */ 19 | // "noEmit": true, /* Do not emit outputs. */ 20 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 21 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 22 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 23 | 24 | /* Strict Type-Checking Options */ 25 | "strict": true, /* Enable all strict type-checking options. */ 26 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 27 | "strictNullChecks": true, /* Enable strict null checks. */ 28 | "strictFunctionTypes": true, /* Enable strict checking of function types. */ 29 | "strictPropertyInitialization": false, /* Enable strict checking of property initialization in classes. */ 30 | "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 31 | "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 32 | 33 | /* Additional Checks */ 34 | "noUnusedLocals": true, /* Report errors on unused locals. */ 35 | "noUnusedParameters": true, /* Report errors on unused parameters. */ 36 | "noImplicitReturns": true, /* Report error when not all code paths in function return a currentValue. */ 37 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 38 | 39 | /* Module Resolution Options */ 40 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 41 | // "baseUrl": "./", /* Base directory to subscribe non-absolute module names. */ 42 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 43 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 44 | // "typeRoots": [], /* List of folders to include type definitions from. */ 45 | // "types": [], /* Type declaration files to be included in compilation. */ 46 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 47 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 48 | // "preserveSymlinks": true, /* Do not subscribe the real path of symlinks. */ 49 | 50 | /* Source Map Options */ 51 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 52 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 53 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 54 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 55 | 56 | /* Experimental Options */ 57 | "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 58 | "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */ 59 | }, 60 | "include": ["src", "examples"] 61 | } 62 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-config-standard", 4 | "tslint-config-prettier" 5 | ], 6 | "rules": { 7 | "curly": false 8 | } 9 | } 10 | --------------------------------------------------------------------------------