├── .gitignore ├── .gitmodules ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── docs ├── API.md └── Language.md ├── examples ├── elevator.json └── elevator │ ├── SoWhat.mp3 │ ├── audio1.mp3 │ └── audio2.mp3 ├── index.html ├── package.json ├── src ├── dispatch.ts ├── game.ts ├── gameActions.ts ├── keyPathify.ts ├── node.ts ├── nodeBag.ts ├── nodeGraph.ts ├── predicate.ts ├── state.ts └── story.ts ├── tests ├── gameTests.ts ├── nodeBagTests.ts ├── nodeGraphTests.ts ├── nodeTests.ts ├── passageTests.ts └── predicateTests.ts ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules 3 | npm-debug.log 4 | # Created by https://www.gitignore.io/api/osx 5 | 6 | ### OSX ### 7 | .DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Icon must end with two \r 12 | Icon 13 | 14 | 15 | # Thumbnails 16 | ._* 17 | 18 | # Files that might appear in the root of a volume 19 | .DocumentRevisions-V100 20 | .fseventsd 21 | .Spotlight-V100 22 | .TemporaryItems 23 | .Trashes 24 | .VolumeIcon.icns 25 | 26 | # Directories potentially created on remote AFP share 27 | .AppleDB 28 | .AppleDesktop 29 | Network Trash Folder 30 | Temporary Items 31 | .apdisk 32 | 33 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lazerwalker/storyboard/21139c3f6a4439a1a1217474271c56c16b237ade/.gitmodules -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: node 3 | git: 4 | submodules: false -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 MIT Media Lab 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Storyboard 2 | 3 | [![CI Status](http://img.shields.io/travis/lazerwalker/storyboard.svg?style=flat)](https://travis-ci.org/lazerwalker/storyboard) 4 | 5 | Storyboard is a general-purpose engine for multilinear/nonlinear storytelling. It's written in TypeScript, and intended to be embedded within another game or application. 6 | 7 | **As a warning, this is academic software that isn't particularly suited for production use by people who didn't build it**. I'd like to get this production-ready at some point, but can make no promises about a timeline. If you're looking for a narrative engine you can ship in your commercial game today, you may want to check out [Yarn Spinner](https://yarnspinner.dev) or [Ink](https://www.inklestudios.com/ink/), although neither quite has a general-purpose storylet system like Storyboard does/did. 8 | 9 | Storyboard consists of two parts: a domain-specific language for authors to write stories (that superficially looks a bit like [Ink](https://github.com/inkle/ink)) and a runtime narrative engine designed to be embedded within a larger game project. 10 | 11 | This repo specifically contains the runtime engine. A few other projects exist: 12 | 13 | * [https://github.com/lazerwalker/storyboard-lang](https://github.com/lazerwalker/storyboard-lang) is the language compiler. This repo includes it as a dependency via npm. 14 | * [https://github.com/lazerwalker/storyboard-iOS](https://github.com/lazerwalker/storyboard-ios) is an iOS framework that provides a native Swift API layer on top of Storyboard running within an embedded JS runtime. It also includes a working sample app. 15 | 16 | 17 | ## Why Storyboard? 18 | 19 | Storyboard draws on a rich history of choice-based interactive fiction platforms. It differentiates itself with two main design philophies: 20 | 21 | ### Be "just" a narrative engine 22 | 23 | Many modern IF systems are written assuming the main way people will interact with your game is by reading text and tapping buttons. More complex interactions are possible, but usually require ugly plumbing. 24 | 25 | Storyboard was originally designed to be used in site-specific audio installations that responded to various sources of smartphone data (e.g. indoor location technology, device motion sensors, and external web APIs for things like weather). As a result, it consciously has a very small footprint. It knows how to take arbitrary input, modify its internal state as a result, and spit out arbitrary output as a result. That's it. It's a single-purpose tool that exists to manage the narrative flow of your game, leaving the rest to your own engine. 26 | 27 | Storyboard is currently being used in production for projects that range from [a site-specific poetry walk](https://lazerwalker.com/flaneur) (a smartphone app using GPS, synthesized audio, and neural network-generated text) to powering the tutorial of a [game played on a 90-year-old telephone switchboard](https://lazerwalker.com/hellooperator). 28 | 29 | This means it's a bit less accessible than tools like Twine, which can be used to create complete works without any formal coding ability. Storyboard is intended to be integrated in projects that have at least one person writing code. By designing for that specific use case, we can make something that's as easy as possible to use for both writers and programmers. 30 | 31 | ### The combination of finite state machines AND triggers 32 | 33 | There are two common approaches for modeling choice-based interactive fiction. 34 | 35 | Engines like [Twine](https://twinery.org), [Ink](https://github.com/inkle/ink), [ChoiceScript](https://www.choiceofgames.com/make-your-own-games/choicescript-intro/), and [Yarn](https://github.com/InfiniteAmmoInc/Yarn) are essentially **finite state machines**. A player's journey through the game can be conceptualized as traversing a node graph, like you see in Twine's literal editor: the choices players make are essentially edges that transition between nodes of content. 36 | 37 | Engines like [StoryNexus](http://www.storynexus.com/) and Valve's [Left4Dead dialog system](http://gdcvault.com/play/1015528/AI-driven-Dynamic-Dialog-through) are what I call **trigger-based** systems. These are sometimes called quality-based narrative, or salience-based narrative, or event-driven narrative, but all describe roughly the same general concept. You have a giant collection of possible pieces of content that each have various prerequites based on the game state. When your game state changes, the system tries to figure out the most appropriate bit (or bits) of content to surface, and presents them to the player. _[ed: while there was no universal agreed-upon term to describe this sort of narrative system when I initially wrote this in 2015 or 2016, today you'd typically use the term [storylets](https://emshort.blog/2019/11/29/storylets-you-want-them/).]_ 38 | 39 | Both of these systems are incredibly powerful, and have been used to make countless wonderful things. But each has strengths and weaknesses, different types of interactions or stories that are easier or more difficult to author using a set of tools. 40 | 41 | Storyboard gives you the best of both worlds by including both a state machine-based system and a trigger-based system, with deep interoperability. Write part of your story as a Twine-style node graph, and write other parts as StoryNexus-style storylets! Since all text is still written using the same writer-friendly Storyboard syntax, it's easy to hop back and forth. 42 | 43 | 44 | ## How do I use this? 45 | 46 | Documentation lives in the `docs` folder of this repo! 47 | 48 | If you're an **author** looking to write stories in Storyboard, check out the [language reference](https://github.com/lazerwalker/storyboard/blob/master/docs/Language.md). 49 | 50 | If you're a **programmer** looking to integrate Storyboard into your existing game engine, check out the [runtime API reference](https://github.com/lazerwalker/storyboard/blob/master/docs/API.md). 51 | 52 | (Not that I'm suggesting that being an author and a programmer are mutually exclusive!) 53 | 54 | 55 | ## Setup 56 | 57 | So, this really isn't yet suitable for people who aren't me to use. I'm avoiding publishing it to npm until it's slightly more stable. As a result, using this is a wee bit involved: 58 | 59 | 1. Clone this repo: `git clone git@github.com:lazerwalker/storyboard.git` 60 | 2. Run `yarn install` to install dependencies 61 | 3. Run `yarn run build` to compile the library. It'll output a `dist/bundle.js` file. If you use TypeScript, `dist/types` will contain type definitions. 62 | 63 | The generated production library is built as a [UMD](https://github.com/umdjs/umd) module, so it's usable in Node, in-browser, etc. 64 | 65 | If you're using it in a browser context, its namespace is placed in the global variable `storyboard`. 66 | 67 | 68 | ## Development Setup 69 | 70 | Looking to hack on Storyboard yourself? 71 | 72 | Webpack is used to compile Storyboard. You can run it via `yarn run build`. You can also manually run webpack (`./node_modules/.bin/webpack`, or a globally-installed version) with whatever other args you'd like, to e.g. enable watch mode. (Better dev support is coming!) 73 | 74 | The `tests` folder contains a fair number of BDD-style tests. `yarn test` runs 'em. 75 | 76 | 77 | ## License 78 | 79 | This project is licensed under the MIT License. See the LICENSE file in this repository for more information. 80 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | # Runtime Engine Reference 2 | 3 | So you're interested in integrating Storyboard into your own game? Awesome! If your project can import a JavaScript module, using Storyboard is super easy. 4 | 5 | The first thing you need to do is create a new Game object with your story: 6 | 7 | ```js 8 | import { Game } from 'storyboard'; 9 | 10 | const story = ... // This should be a string containing your Storyboard story 11 | const game = new Game(story) 12 | 13 | ``` 14 | 15 | If you're using Storyboard in a browser (or browser-like) environment by including the compiled JS file directly via ` 7 | 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "storyboard-engine", 3 | "version": "0.1.0", 4 | "description": "An engine for nonlinear and multilinear storytelling", 5 | "main": "dist/bundle.js", 6 | "types": "dist/types/game.d.ts", 7 | "scripts": { 8 | "test": "mocha-webpack tests", 9 | "prepublish": "npm run build", 10 | "build": "webpack" 11 | }, 12 | "repository": "git@github.com:lazerwalker/storyboard.git", 13 | "author": "Mike Lazer-Walker ", 14 | "license": "ISC", 15 | "homepage": "https://github.com/lazerwalker/storyboard", 16 | "devDependencies": { 17 | "@types/chai": "^4.0.4", 18 | "@types/lodash": "^4.14.78", 19 | "@types/mocha": "^2.2.43", 20 | "@types/node": "^8.0.33", 21 | "@types/seedrandom": "^2.4.27", 22 | "@types/sinon": "^2.3.6", 23 | "@types/sinon-chai": "^2.7.29", 24 | "chai": "^3.4.1", 25 | "mocha": "^2.3.4", 26 | "mocha-webpack": "^1.0.1", 27 | "sinon": "^4.1.2", 28 | "sinon-chai": "^2.8.0", 29 | "ts-loader": "^3.1.1", 30 | "ts-node": "^3.3.0", 31 | "typescript": "^2.5.3", 32 | "webpack": "^3.8.1", 33 | "webpack-dev-server": "^2.9.4" 34 | }, 35 | "dependencies": { 36 | "lodash": "^4.17.4", 37 | "seedrandom": "^2.4.2", 38 | "storyboard-lang": "^0.0.1" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/dispatch.ts: -------------------------------------------------------------------------------- 1 | export type Dispatch = (action: string, data: Object) => void; 2 | -------------------------------------------------------------------------------- /src/game.ts: -------------------------------------------------------------------------------- 1 | // TODO: Look into how much of the lodash functionality we're using 2 | // has been pulled into vanilla TS/JS 3 | import * as _ from "lodash" 4 | 5 | /* Identified parser issues 6 | - Spaces in node IDs 7 | - Can we avoid needing node IDs for bag nodes? 8 | - asterisks in comments 9 | - text like [[this]] in a variable setting 10 | - to confirm: if you have a passage with a predicate, can you have (a) multiple passages tied to that predicate, and/or (b) non-predicated passages after it? 11 | */ 12 | 13 | import keyPathify from "./keyPathify" 14 | 15 | import * as Actions from "./gameActions" 16 | 17 | import { Node } from './node' 18 | import { State } from './state' 19 | import { Story } from './story' 20 | import * as Parser from 'storyboard-lang' 21 | import { Dispatch } from './dispatch' 22 | 23 | export { default as keyPathify } from './keyPathify' 24 | 25 | export type OutputCallback = ((content: string, passageId: Parser.PassageId, track: String) => void) 26 | export type ObserverCallback = ((content: any) => void) 27 | 28 | export class Game { 29 | observers: {[keypath: string]: ObserverCallback[]} 30 | valid: boolean = true 31 | 32 | constructor(storyData: string|Parser.Story, initialState?: State) { 33 | let story: Parser.Story 34 | if (typeof storyData === "string") { 35 | story = Parser.parseString(storyData)! 36 | if (!story) { 37 | // I'm having trouble handling this error properly. This is a hack. 38 | this.valid = false 39 | console.log("Throwing invalid story") 40 | throw("Invalid story") 41 | } 42 | } else { 43 | story = storyData 44 | } 45 | 46 | let dispatch = _.bind(this.receiveDispatch, this) as Dispatch 47 | this.story = new Story(story, dispatch) 48 | this.state = new State(initialState) 49 | 50 | this.started = false; 51 | 52 | this.outputs = {}; 53 | this.observers = {}; 54 | 55 | var that = this; // TODO: I don't think this does anything? 56 | this.addOutput("wait", (timeout: string, passageId: Parser.PassageId) => { 57 | setTimeout(() => { 58 | this.completePassage(passageId); 59 | }, parseInt(timeout)) 60 | }); 61 | 62 | 63 | // TODO: For now, comments are mistakenly parsed as passages 64 | // This lets us just immediately move past the comment for now. 65 | // To be fixed properly at the parser level -- comments shouldn't be stored as passages! 66 | this.addOutput('', (_, passageId) => { 67 | this.completePassage(passageId) 68 | }) 69 | } 70 | 71 | readonly story: Story; 72 | state: State; 73 | 74 | outputs: {[name: string]: OutputCallback[]} 75 | started: boolean; 76 | 77 | stateListener?:((state: State) => void) 78 | jsonStateListener?: ((serializedState: string) => void) 79 | 80 | receiveDispatch(action: string, data: any) { 81 | if (action === Actions.OUTPUT) { 82 | let outputs = this.outputs[data.type]; 83 | if (!outputs) return; 84 | 85 | for (let outputCallback of outputs) { 86 | const string = data.content.replace( 87 | /\{(.+?)\}/g, 88 | (match: string, keyPath: string) => _(this.state).get(keyPath) 89 | ); 90 | outputCallback(string, data.passageId, data.track); 91 | } 92 | } else if (action === Actions.MAKE_GRAPH_CHOICE) { 93 | if (!this.state.graph.currentNodeId) return 94 | 95 | this.state.graph.previousNodeId = this.state.graph.currentNodeId; 96 | this.state.graph.nodeHistory.unshift(this.state.graph.currentNodeId) 97 | this.state.graph.previousChoice = (Object).assign({}, data); 98 | this.state.graph.choiceHistory.unshift((Object).assign({}, data)); 99 | 100 | this.story.bag.checkNodes(this.state); 101 | 102 | this.receiveDispatch(Actions.CHANGE_GRAPH_NODE, data.nodeId); 103 | 104 | } else if (action === Actions.CHANGE_GRAPH_NODE) { 105 | this.state.graph.currentNodeId = data; 106 | this.state.graph.nodeComplete = false; 107 | this.state.graph.currentPassageIndex = -1; 108 | 109 | if (this.story.graph) { 110 | this.story.graph.startNextPassage(this.state); 111 | } 112 | 113 | this.story.bag.checkNodes(this.state); 114 | 115 | } else if (action === Actions.CHANGE_GRAPH_PASSAGE) { 116 | this.state.graph.currentPassageIndex = data; 117 | 118 | if (this.story.graph) { 119 | this.story.graph.playCurrentPassage(this.state); 120 | } 121 | } else if (action === Actions.COMPLETE_GRAPH_NODE) { 122 | this.state.graph.nodeComplete = true; 123 | 124 | if (this.story.graph) { 125 | this.story.graph.checkChoiceTransitions(this.state); 126 | } 127 | 128 | this.story.bag.checkNodes(this.state); 129 | 130 | } else if (action === Actions.TRIGGERED_BAG_NODES) { 131 | _.forEach(data, (node: Node, track: string) => { 132 | const nodeId = node.nodeId; 133 | 134 | this.state.bag.activePassageIndexes[nodeId] = 0; 135 | this.state.bag.activeTracks[node.track] = nodeId 136 | this.story.bag.playCurrentPassage(nodeId, this.state); 137 | }) 138 | } else if (action === Actions.CHANGE_BAG_PASSAGE) { 139 | let [nodeId, passageIndex] = data; 140 | this.state.bag.activePassageIndexes[nodeId] = passageIndex; 141 | this.story.bag.playCurrentPassage(nodeId, this.state); 142 | 143 | } else if (action === Actions.COMPLETE_BAG_NODE) { 144 | const nodeId = data; 145 | delete this.state.bag.activePassageIndexes[nodeId]; 146 | 147 | const node = this.story.bag.nodes[nodeId] 148 | delete this.state.bag.activeTracks[node.track!] 149 | 150 | if (!this.state.bag.nodeHistory[nodeId]) { 151 | this.state.bag.nodeHistory[nodeId] = 0; 152 | } 153 | 154 | this.state.bag.nodeHistory[nodeId]++; 155 | this.story.bag.checkNodes(this.state); 156 | 157 | } else if (action === Actions.RECEIVE_INPUT) { 158 | let newState =_.assign({}, this.state) 159 | _.forIn(data, (value: any, key: string) => { 160 | _.set(newState, key, keyPathify(value, this.state, true)) 161 | }); 162 | this.state = {...this.state, ...newState} 163 | 164 | _.forIn(data, (value: any, key: string) => { 165 | if (this.observers[key]) { 166 | _.forEach(this.observers[key], (fn) => fn(value)) 167 | } 168 | }) 169 | 170 | if (this.started) { 171 | if (this.story.graph) { 172 | this.story.graph.checkChoiceTransitions(this.state); 173 | } 174 | 175 | this.story.bag.checkNodes(this.state); 176 | } 177 | } else if (action === Actions.SET_VARIABLES) { 178 | let newState =_.assign({}, this.state) 179 | _.forIn(data, (value: any, key: string) => { 180 | _.set(newState, key, keyPathify(value, this.state, true)) 181 | }); 182 | _.assign(this.state, newState) 183 | 184 | _.forIn(data, (value: any, key: string) => { 185 | if (this.observers[key]) { 186 | _.forEach(this.observers[key], (fn) => fn(value)) 187 | } 188 | }) 189 | 190 | if (this.started) { 191 | if (this.story.graph) { 192 | this.story.graph.checkChoiceTransitions(this.state); 193 | } 194 | 195 | this.story.bag.checkNodes(this.state); 196 | } 197 | } else if (action === Actions.COMPLETE_PASSAGE) { 198 | this.completePassage(data); 199 | } 200 | 201 | this.emitState(); 202 | } 203 | 204 | start() { 205 | this.started = true; 206 | if (this.story.graph && this.story.graph.start) { 207 | this.receiveDispatch(Actions.CHANGE_GRAPH_NODE, this.story.graph.start) 208 | } else { 209 | this.story.bag.checkNodes(this.state) 210 | } 211 | } 212 | 213 | addOutput(type: string, callback: OutputCallback) { 214 | if (!this.outputs[type]) { 215 | this.outputs[type] = []; 216 | } 217 | this.outputs[type].push(callback); 218 | } 219 | 220 | // TODO: What's the difference between this and emitState? 221 | addObserver(type: string, callback: ObserverCallback) { 222 | if (!this.observers[type]) { 223 | this.observers[type] = [] 224 | } 225 | this.observers[type].push(callback) 226 | } 227 | 228 | removeObserver(type: string, callback: ObserverCallback) { 229 | this.observers[type] = _.without(this.observers[type], callback) 230 | } 231 | 232 | receiveInput(type: string, value: any) { 233 | let obj: any = {} 234 | obj[type] = value 235 | this.receiveDispatch(Actions.RECEIVE_INPUT, obj) 236 | } 237 | 238 | receiveMomentaryInput(type: string, value?: any) { 239 | let trueObj: any = {} 240 | trueObj[type] = value || true 241 | this.receiveDispatch(Actions.RECEIVE_INPUT, trueObj) 242 | 243 | let falseObj: any = {} 244 | falseObj[type] = undefined 245 | this.receiveDispatch(Actions.RECEIVE_INPUT, falseObj) 246 | } 247 | 248 | completePassage(passageId: Parser.PassageId) { 249 | if (this.story.graph) { 250 | this.story.graph.completePassage(passageId, this.state); 251 | } 252 | 253 | this.story.bag.completePassage(passageId, this.state); 254 | } 255 | 256 | emitState() { 257 | if (this.stateListener) { 258 | this.stateListener({...this.state}) 259 | } 260 | 261 | if (this.jsonStateListener) { 262 | const json = JSON.stringify(this.state, null, 2) 263 | this.jsonStateListener(json) 264 | } 265 | } 266 | } -------------------------------------------------------------------------------- /src/gameActions.ts: -------------------------------------------------------------------------------- 1 | export const OUTPUT = "output"; 2 | 3 | export const CHANGE_GRAPH_NODE = "changeGraphNode"; 4 | export const MAKE_GRAPH_CHOICE = "makeGraphChoice"; 5 | export const CHANGE_GRAPH_PASSAGE = "changeGraphPassage"; 6 | export const COMPLETE_GRAPH_NODE = "completeGraphNode"; 7 | 8 | export const CHANGE_BAG_PASSAGE = "changeBagNode"; 9 | export const COMPLETE_BAG_NODE = "completeBagNode"; 10 | 11 | export const TRIGGERED_BAG_NODES = "triggeredBagNodes"; 12 | 13 | export const RECEIVE_INPUT = "receiveInput"; 14 | 15 | export const SET_VARIABLES = "setVariables"; 16 | 17 | export const COMPLETE_PASSAGE = "completePassage"; -------------------------------------------------------------------------------- /src/keyPathify.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash' 2 | const seedrandom = require('seedrandom'); 3 | 4 | var seed: string|number|undefined; 5 | var rng = seedrandom(); 6 | 7 | export default function keyPathify(input: string|any, state: any, checkIfDefined = false): any { 8 | // TODO: I'm not sure I like this solution. 9 | if (state.rngSeed && state.rngSeed !== seed) { 10 | seed = state.rngSeed 11 | rng = seedrandom(seed) 12 | } 13 | 14 | // TODO: I'm not sure I like this object solution. 15 | // If I keep it, though, add a TS interface 16 | if (_.isObject(input)) { 17 | if(input.randInt && _.isArray(input.randInt)) { 18 | const lower = input.randInt[0]; 19 | const upper = input.randInt[1]; 20 | return Math.floor(rng() * (upper - lower)) + lower; 21 | } 22 | } 23 | 24 | if (!_.isString(input)) { 25 | return input; 26 | } 27 | 28 | const result = _(state).get(input) 29 | 30 | if (checkIfDefined && _.isUndefined(result)) { 31 | return input; 32 | } 33 | 34 | return result; 35 | } -------------------------------------------------------------------------------- /src/node.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash" 2 | 3 | import checkPredicate from "./predicate" 4 | import * as Actions from "./gameActions" 5 | import * as Parser from "storyboard-lang"; 6 | 7 | import { Dispatch } from './dispatch' 8 | 9 | 10 | export class Node implements Parser.Node { 11 | constructor(data={}, dispatch: Dispatch) { 12 | (Object).assign(this, data) 13 | this.dispatch = dispatch 14 | 15 | if (!this.track) this.track = "default" 16 | } 17 | 18 | readonly nodeId: Parser.NodeId; 19 | readonly passages?: Parser.Passage[]; 20 | readonly choices?: Parser.Choice[]; 21 | readonly track: string; 22 | readonly predicate?: Parser.Predicate; 23 | 24 | // TODO: Documentation is now out-of-date. 25 | // I don't know what behavior is better, or what higher-level abstraction should exist 26 | 27 | // DOUBLE TODO: This is currently broken. It appears that allowRepeats doesn't get properly set 28 | // This was already broken, but new tests/functionality are just now exposing this 29 | readonly allowRepeats?: boolean = true; 30 | 31 | dispatch: Dispatch; 32 | 33 | playPassage(passageIndex: number): void { 34 | if (!this.passages) return // TODO: Better error handling 35 | const passage = this.passages[passageIndex] 36 | if (!passage || !this.dispatch) return 37 | 38 | const hasContent = !_.isUndefined(passage.content) 39 | 40 | if (hasContent) { 41 | const data = _.assign({}, passage, {track: this.track}) 42 | this.dispatch(Actions.OUTPUT, data); 43 | } 44 | 45 | if (passage.set) { 46 | this.dispatch(Actions.SET_VARIABLES, passage.set) 47 | } 48 | 49 | if (!hasContent) { 50 | this.dispatch(Actions.COMPLETE_PASSAGE, passage.passageId); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/nodeBag.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash' 2 | 3 | import checkPredicate from "./predicate" 4 | import * as Actions from "./gameActions" 5 | import * as Parser from "storyboard-lang" 6 | 7 | import { Dispatch } from './dispatch' 8 | import { Node } from './node' 9 | import { State } from './state' 10 | 11 | export class Bag { 12 | constructor(nodes: Parser.NodeBag|undefined, dispatch: Dispatch) { 13 | this.dispatch = dispatch 14 | this.nodes = _.mapValues(nodes || {}, (node, key) => { 15 | // TODO: Rip this out into a "BagNode" object? Should these apply to normal nodes? 16 | if (!node.predicate) { node.predicate = {} } 17 | var newPredicate: Parser.Predicate = { 18 | track: "default" 19 | } 20 | 21 | const activeKeyPath = `bag.activePassageIndexes.${node.nodeId}`; 22 | newPredicate[activeKeyPath] = {"exists": false}; 23 | 24 | if(!node.allowRepeats) { 25 | const finishedKeypath = `bag.nodeHistory.${node.nodeId}`; 26 | newPredicate[finishedKeypath] = {"exists": false}; 27 | } 28 | 29 | node.predicate = (Object).assign({}, node.predicate, newPredicate); 30 | return new Node(node, dispatch) 31 | }); 32 | } 33 | 34 | readonly nodes: {[nodeId: string]: Node} 35 | readonly dispatch: Dispatch 36 | 37 | checkNodes(state: State) { 38 | const filteredNodes = _.filter(this.nodes, (node) => checkPredicate(node.predicate, state)); 39 | if (filteredNodes.length > 0 && this.dispatch) { 40 | const singleNodesByTrack = _(filteredNodes) 41 | .groupBy("track") 42 | .mapValues(_.sample) // TODO: Eventually let people pass in their own fitness fn 43 | .omitBy((_, track) => state.bag.activeTracks[track]) 44 | .value() 45 | 46 | this.dispatch(Actions.TRIGGERED_BAG_NODES, singleNodesByTrack); 47 | } 48 | } 49 | 50 | playCurrentPassage(nodeId: Parser.NodeId, state: State) { 51 | const node = this.nodes[nodeId]; 52 | if (!node) return; 53 | 54 | const passageIndex = state.bag.activePassageIndexes[nodeId]; 55 | 56 | node.playPassage(passageIndex) 57 | } 58 | 59 | completePassage(passageId: Parser.PassageId, state: State) { 60 | const nodes = _.chain(state.bag.activePassageIndexes) 61 | .keys() 62 | .map((nodeId: string): Node => this.nodes[nodeId]) 63 | .value() 64 | 65 | const node = _.find(nodes, (node: Node) => { 66 | return _.chain(node.passages) 67 | .map('passageId') 68 | .includes(passageId) 69 | .value() 70 | }) 71 | 72 | if (!node || !node.passages) return; 73 | 74 | const currentIndex = state.bag.activePassageIndexes[node.nodeId]; 75 | const currentPassage = node.passages[currentIndex]; 76 | if (passageId !== currentPassage.passageId) return; 77 | 78 | let found = false 79 | let newPassageIndex = currentIndex 80 | 81 | while(!found) { 82 | newPassageIndex = newPassageIndex! + 1 83 | if (_.isUndefined(node.passages) || newPassageIndex >= node.passages!.length) { 84 | found = true; 85 | this.dispatch(Actions.COMPLETE_GRAPH_NODE, node.nodeId) 86 | } else { 87 | const newPassage = node.passages![newPassageIndex]; 88 | if ((!newPassage.predicate) || checkPredicate(newPassage.predicate, state)) { 89 | found = true; 90 | this.dispatch(Actions.CHANGE_BAG_PASSAGE, [node.nodeId, newPassageIndex]) 91 | } else { 92 | console.log("failed predicate") 93 | } 94 | } 95 | } 96 | } 97 | } -------------------------------------------------------------------------------- /src/nodeGraph.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash' 2 | 3 | import checkPredicate from "./predicate" 4 | import * as Actions from "./gameActions" 5 | import * as Parser from "storyboard-lang" 6 | 7 | import { Dispatch } from './dispatch' 8 | import { Node } from './node' 9 | import { State } from './state' 10 | 11 | export class Graph implements Parser.StoryGraph { 12 | 13 | constructor(graph: Parser.StoryGraph|undefined, dispatch: Dispatch) { 14 | if (graph) { 15 | this.nodes = _.mapValues(graph.nodes, (n: Parser.Node) => new Node(n, dispatch)) 16 | this.start = graph.start || Object.keys(this.nodes)[0]; 17 | } 18 | this.dispatch = dispatch 19 | } 20 | 21 | readonly nodes: {[name: string]: Node} 22 | readonly start: Parser.NodeId 23 | readonly dispatch: Dispatch 24 | 25 | completePassage(passageId: Parser.PassageId, state: State) { 26 | const currentNode = this._nodeWithId(state.graph.currentNodeId) 27 | if (!currentNode || !currentNode.passages || _.isUndefined(state.graph.currentPassageIndex)) return; 28 | 29 | const currentPassage = currentNode.passages![state.graph.currentPassageIndex!]; 30 | if (passageId !== currentPassage.passageId) return; 31 | 32 | this.startNextPassage(state) 33 | } 34 | 35 | startNextPassage(state: State) { 36 | const currentNode = this._nodeWithId(state.graph.currentNodeId) 37 | if (!currentNode || _.isUndefined(state.graph.currentPassageIndex)) return; 38 | 39 | let found = false 40 | let newPassageIndex = state.graph.currentPassageIndex 41 | while(!found) { 42 | newPassageIndex = newPassageIndex! + 1 43 | if (_.isUndefined(currentNode.passages) || newPassageIndex >= currentNode.passages!.length) { 44 | found = true; 45 | this.dispatch(Actions.COMPLETE_GRAPH_NODE, currentNode.nodeId) 46 | } else { 47 | const newPassage = currentNode.passages![newPassageIndex]; 48 | if ((!newPassage.predicate) || checkPredicate(newPassage.predicate, state)) { 49 | found = true; 50 | this.dispatch(Actions.CHANGE_GRAPH_PASSAGE, newPassageIndex) 51 | } 52 | } 53 | } 54 | } 55 | 56 | checkChoiceTransitions(state: State) { 57 | const node = this._nodeWithId(state.graph.currentNodeId) 58 | 59 | if (!(state.graph.nodeComplete && node && node.choices)) { return } 60 | 61 | let choices = node.choices.filter(function(choice) { 62 | return checkPredicate(choice.predicate, state) 63 | }); 64 | 65 | if (choices.length > 0 && this.dispatch) { 66 | this.dispatch(Actions.MAKE_GRAPH_CHOICE, choices[0]); 67 | } 68 | } 69 | 70 | playCurrentPassage(state: State) { 71 | const node = this._nodeWithId(state.graph.currentNodeId) 72 | const passageIndex = state.graph.currentPassageIndex 73 | 74 | if (!node || _.isUndefined(passageIndex)) return; 75 | 76 | node.playPassage(passageIndex!) 77 | } 78 | 79 | //-- 80 | 81 | _nodeWithId(nodeId: Parser.NodeId|undefined): Node|undefined { 82 | if (nodeId) { 83 | return this.nodes[nodeId]; 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /src/predicate.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash' 2 | import keyPathify from "./keyPathify" 3 | 4 | import { State } from './state' 5 | import { Predicate } from 'storyboard-lang' 6 | 7 | export default function checkPredicate(predicate: Predicate|undefined, state: any) { 8 | if (!predicate) { return true } 9 | 10 | console.log(predicate) 11 | 12 | function check(memo: any, obj: any, key: string): boolean { 13 | // TODO: If this is slow, only do valueForKeyPath for strings that need it 14 | const value = keyPathify(key, state) || key 15 | // console.log(`Check (memo: ${memo}): ${JSON.stringify(obj)} against '${key}' (value: '${value}')`) 16 | 17 | if (key === "or") { 18 | if (!_.isArray(obj)) { return false } 19 | return _.reduce(obj, ((memo: any, p: Predicate) => memo || checkPredicate(p, state)), false) 20 | } 21 | 22 | if (key === "and") { 23 | if (!_.isArray(obj)) { return false} 24 | return _.reduce(obj, ((memo: any, p: Predicate) => memo && checkPredicate(p, state)), true) 25 | } 26 | 27 | if (key === "not") { 28 | return !checkPredicate(obj, state) 29 | } 30 | 31 | if (!_.isUndefined(obj.or) && _.isArray(obj.or)) { 32 | // since check is built around the assumption that everything is ANDed, 33 | // we need to pass in true or else it will short-circuit. 34 | // This is pretty sloppy, and suggests an eventual better model is needed. 35 | memo = memo && _.reduce(obj.or, ((m: any, o: any, k: string) => m || check(true, o, key)), false) 36 | } 37 | 38 | if (!_.isUndefined(obj.eq)) { 39 | memo = memo && (value === obj.eq || (value === keyPathify(obj.eq, state, true))) 40 | } 41 | 42 | // TODO: No tests for this 43 | if (!_.isUndefined(obj.neq)) { 44 | memo = memo && (value !== obj.neq && (value !== keyPathify(obj.neq, state, true))) 45 | } 46 | 47 | if (!_.isUndefined(obj.gte)) { 48 | memo = memo && (value >= keyPathify(obj.gte, state, true)); 49 | } 50 | if (!_.isUndefined(obj.lte)) { 51 | memo = memo && (value <= keyPathify(obj.lte, state, true)); 52 | } 53 | if (!_.isUndefined(obj.exists)) { 54 | memo = memo && (obj.exists !== _.isUndefined(keyPathify(value, state))); 55 | } 56 | return memo; 57 | } 58 | 59 | return _.reduce(predicate, check, true) 60 | } -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | import { initial } from 'lodash' 2 | import * as Parser from 'storyboard-lang' 3 | import { Node } from './node' 4 | 5 | export class State { 6 | constructor(initialState?: any) { 7 | this.graph = new GraphState() 8 | this.bag = new BagState() 9 | 10 | if (initialState) { 11 | Object.keys(initialState).forEach(key => { 12 | if (key === "graph" || key === "bag") return 13 | this[key] = initialState[key] 14 | }) 15 | } 16 | } 17 | 18 | /** Whenever you set any state (either via `game.receiveInput` or `set foo to bar` in script), it gets shoved onto this object. */ 19 | [key:string]: any; 20 | 21 | /** You can manually set an RNG seed for consistent pseudo-randomness. 22 | * Useful for e.g. testing. 23 | */ 24 | rngSeed?: string|number 25 | 26 | graph: GraphState; 27 | bag: BagState; 28 | } 29 | 30 | export class GraphState { 31 | /** Every choice the player has made in the graph. 32 | * Reverse chronological, so the 0th item is the most recent choice. 33 | */ 34 | choiceHistory: Parser.Choice[] = [] 35 | 36 | /** The most recent choice the player took. */ 37 | previousChoice?: Parser.Choice 38 | 39 | /** The IDs of every completed graph node the player has visited, in reverse chronological order. 40 | * The 0th item is the *previous* node. 41 | * TODO: Should that be changed so that it includes the active node? 42 | */ 43 | 44 | nodeHistory: Parser.NodeId[] = [] 45 | 46 | /** The ID of the current active graph node */ 47 | currentNodeId?: Parser.NodeId 48 | 49 | /** The ID of the previous completed node the player has visited */ 50 | previousNodeId?: Parser.NodeId 51 | 52 | /** True if the current graph node has been completed, and is e.g. waiting for a choice to be made. */ 53 | nodeComplete: boolean 54 | 55 | /** The index of the current active passage (starting with 0) within the active node */ 56 | currentPassageIndex?: number 57 | } 58 | 59 | export class BagState { 60 | /** Indexes of all active bag passages, keyed by nodeId 61 | * e.g. { "foo": 0, "bar": 2} means that the "foo" node is playing its first 62 | * passage, and "bar" its second 63 | */ 64 | activePassageIndexes: {[nodeId: string]: number} = {} 65 | 66 | /** The number of times each node has been completed. 67 | * Keys are nodeIds, values are play counts 68 | */ 69 | nodeHistory: {[key: string]: number} = {} 70 | 71 | /** Whether a given track is active or not. 72 | * Keys are track names. If a track is active, its value will be the node name 73 | */ 74 | activeTracks: {[trackName: string]: string|undefined} = {} 75 | } -------------------------------------------------------------------------------- /src/story.ts: -------------------------------------------------------------------------------- 1 | import { Bag } from "./nodeBag" 2 | import { Graph } from "./nodeGraph" 3 | 4 | import * as Parser from "storyboard-lang" 5 | 6 | import { Dispatch } from './dispatch' 7 | 8 | export class Story { 9 | constructor(storyData: Parser.Story, dispatch: Dispatch) { 10 | if (storyData.graph) { 11 | this.graph = new Graph(storyData.graph, dispatch) 12 | } 13 | 14 | this.bag = new Bag(storyData.bag, dispatch) 15 | } 16 | 17 | graph?: Graph 18 | bag: Bag 19 | } -------------------------------------------------------------------------------- /tests/gameTests.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | import * as sinonChai from 'sinon-chai' 3 | import * as sinon from 'sinon' 4 | 5 | chai.use(sinonChai); 6 | const expect = chai.expect 7 | 8 | import { Game } from '../src/game' 9 | import * as Parser from 'storyboard-lang' 10 | import * as Actions from '../src/gameActions' 11 | 12 | describe("initialization", function() { 13 | describe("creating a nodeGraph", function() { 14 | var node, game: Game; 15 | beforeEach(function() { 16 | const story = ` 17 | start: node 18 | # node 19 | ` 20 | game = new Game(story) 21 | }); 22 | 23 | it("should create a valid nodeGraph with the passed options", function() { 24 | expect(game.story.graph).to.exist; 25 | 26 | expect(game.story!.graph!.start).to.equal("node"); 27 | expect(Object.keys(game.story!.graph!.nodes)).to.have.length(1); 28 | }); 29 | 30 | it("shouldn't start the game", function() { 31 | expect(game.state.graph.currentNodeId).to.be.undefined; 32 | }); 33 | }); 34 | 35 | describe("creating a nodeBag", function() { 36 | it("should create a valid nodeBag with the passed nodes", function() { 37 | const story = `## node` 38 | const game = new Game(story) 39 | 40 | expect(game.story.bag).to.exist; 41 | expect(Object.keys(game.story.bag.nodes)).to.have.length(1) 42 | }); 43 | }); 44 | }); 45 | 46 | describe("playing the node graph", function() { 47 | context("when starting the game", function() { 48 | context.skip("when there is a quoted start node", () => { 49 | it("should play the appropriate content via an output", function() { 50 | const game = new Game(` 51 | start: "node" 52 | 53 | # node 54 | text: "Hello World!" 55 | `) 56 | 57 | const callback = sinon.spy(); 58 | game.addOutput("text", callback); 59 | 60 | game.start(); 61 | expect(callback).to.have.been.calledWith("Hello World!", sinon.match.any); 62 | }); 63 | }) 64 | 65 | context("when there is an unquoted start node", () => { 66 | it("should play the appropriate content via an output", function() { 67 | const game = new Game(` 68 | start: node 69 | 70 | # node 71 | text: "Hello World!" 72 | `) 73 | 74 | const callback = sinon.spy(); 75 | game.addOutput("text", callback); 76 | 77 | game.start(); 78 | expect(callback).to.have.been.calledWith("Hello World!", sinon.match.any); 79 | }); 80 | }) 81 | }); 82 | 83 | context("when a node has no passages", function() { 84 | let game: Game, callback: any; 85 | beforeEach(function() { 86 | game = new Game(` 87 | start: first 88 | 89 | # first 90 | -> second: [ continue is true ] 91 | 92 | # second 93 | text: "Goodbye World!" 94 | `) 95 | 96 | callback = sinon.spy(); 97 | game.addOutput("text", callback); 98 | game.start(); 99 | }); 100 | 101 | context("when a predicate has not yet been met", function() { 102 | it("should wait for the predicate to be met", function() { 103 | expect(callback).not.to.have.been.called 104 | }) 105 | }) 106 | 107 | context("when a predicate has been met", function() { 108 | it("should go immediately on", function() { 109 | game.receiveInput("continue", true) 110 | expect(callback).to.have.been.calledWith("Goodbye World!", sinon.match.any); 111 | }); 112 | }) 113 | }) 114 | 115 | context("when there are multiple passages", function() { 116 | describe("waiting for completion", function() { 117 | let first, second, game: Game, callback: any; 118 | beforeEach(() => { 119 | game = new Game(` 120 | # node 121 | text: First 122 | text: Second 123 | `) 124 | 125 | callback = sinon.spy(); 126 | game.addOutput("text", callback); 127 | 128 | game.start(); 129 | }); 130 | 131 | it("shouldn't play the second passage when the first isn't done", function() { 132 | expect(callback).to.have.been.calledWith("First", "0") 133 | expect(callback).not.to.have.been.calledWith("Second", sinon.match.any); 134 | }); 135 | 136 | it("should play the second passage after the first is done", function() { 137 | game.completePassage("0"); 138 | expect(callback).to.have.been.calledWith("Second", sinon.match.any); 139 | }) 140 | }) 141 | 142 | describe("when a passage has no content", function() { 143 | let first, second, game, callback: any; 144 | beforeEach(function() { 145 | game = new Game(` 146 | # node 147 | set foo to 5 148 | text: Second 149 | `) 150 | 151 | callback = sinon.spy(); 152 | game.addOutput("text", callback); 153 | game.start() 154 | }) 155 | 156 | it("should go on to the next passage", function() { 157 | expect(callback).to.have.been.calledWith("Second", sinon.match.any) 158 | }) 159 | 160 | context("when it's the last one in a node", function() { 161 | let game, callback: any; 162 | beforeEach(function() { 163 | game = new Game(` 164 | # first 165 | set foo to 5 166 | -> second 167 | 168 | # second 169 | text: Second 170 | `) 171 | 172 | callback = sinon.spy(); 173 | game.addOutput("text", callback); 174 | game.start() 175 | }) 176 | 177 | it("should go on to the next node", function() { 178 | expect(callback).to.have.been.calledWith("Second", sinon.match.any) 179 | }) 180 | }) 181 | }) 182 | 183 | describe("when one of them should be skipped", function() { 184 | describe("when there are passages after the skipped one", function() { 185 | let game: Game, callback: any; 186 | context("when the appropriate passage has no predicate", function() { 187 | beforeEach(function() { 188 | game = new Game(` 189 | # node 190 | [ foo is false ] 191 | text: First 192 | text: Second 193 | `) 194 | 195 | callback = sinon.spy(); 196 | game.addOutput("text", callback); 197 | game.receiveInput("foo", true) 198 | }) 199 | 200 | it("should go on to the next passage", function() { 201 | game.start(); 202 | 203 | expect(callback).not.to.have.been.calledWith("First", sinon.match.any) 204 | expect(callback).to.have.been.calledWith("Second", sinon.match.any) 205 | }) 206 | }); 207 | 208 | context("when the appropriate passage has a matching predicate", function() { 209 | beforeEach(function() { 210 | game = new Game(` 211 | # node 212 | [ foo is false ] 213 | text: First 214 | [ bar is true ] 215 | text: Second 216 | `) 217 | 218 | callback = sinon.spy(); 219 | game.addOutput("text", callback); 220 | game.receiveInput("foo", true) 221 | game.receiveInput("bar", true) 222 | }) 223 | 224 | it("should go to the next passage", function() { 225 | game.start(); 226 | expect(callback).not.to.have.been.calledWith("First", sinon.match.any) 227 | expect(callback).to.have.been.calledWith("Second", sinon.match.any) 228 | }) 229 | }) 230 | }) 231 | 232 | describe("when the skipped passage is the last one", function() { 233 | it("should complete the node", function() { 234 | const game = new Game(` 235 | # node 236 | [ foo exists ] 237 | text: "Hi!" 238 | `) 239 | 240 | const callback = sinon.spy(); 241 | game.addOutput("text", callback); 242 | game.start(); 243 | 244 | expect(callback).not.to.have.been.calledWith("Hi!", sinon.match.any); 245 | expect(game.state.graph.nodeComplete).to.be.true; 246 | }) 247 | }) 248 | }) 249 | }); 250 | 251 | context("when making a choice using a push-button variable", function() { 252 | let game: Game, callback: any; 253 | beforeEach(function() { 254 | game = new Game(` 255 | # start 256 | -> buttonPressed: [button] 257 | 258 | # buttonPressed 259 | text: Goodbye World! 260 | `) 261 | 262 | callback = sinon.spy(); 263 | game.addOutput("text", callback); 264 | game.start(); 265 | 266 | game.receiveMomentaryInput("button") 267 | }); 268 | 269 | it("should trigger the appropriate nodes", function() { 270 | expect(callback).to.have.been.calledWith("Goodbye World!", sinon.match.any); 271 | }); 272 | 273 | it("should not affect the actual variable", function() { 274 | expect(game.state.button).to.not.exist 275 | }) 276 | 277 | context("when a value is passed in", function() { 278 | beforeEach(function() { 279 | game = new Game(` 280 | # start 281 | -> buttonPressed: [button is "pushed!"] 282 | 283 | # buttonPressed 284 | text: Goodbye World! 285 | `); 286 | 287 | callback = sinon.spy(); 288 | game.addOutput("text", callback); 289 | game.start(); 290 | 291 | game.receiveMomentaryInput("button", "pushed!") 292 | }); 293 | 294 | it("should trigger the appropriate nodes", function() { 295 | expect(callback).to.have.been.calledWith("Goodbye World!", sinon.match.any); 296 | }); 297 | 298 | it("should not affect the actual variable", function() { 299 | expect(game.state.button).to.not.exist 300 | }) 301 | }) 302 | }); 303 | 304 | context("when making a choice", function() { 305 | let game: Game, callback: any; 306 | beforeEach(function() { 307 | game = new Game(` 308 | # first 309 | text: foobar 310 | -> second: [ counter >= 10 ] 311 | 312 | # second 313 | text: Goodbye World! 314 | `); 315 | 316 | callback = sinon.spy(); 317 | game.addOutput("text", callback); 318 | game.start(); 319 | }); 320 | 321 | describe("waiting for the node to complete", function() { 322 | context("when the output needs to finish first", function() { 323 | it("shouldn't change nodes until the content finishes", function() { 324 | game.receiveInput("counter", 11); 325 | expect(callback).not.to.have.been.calledWith("Goodbye World!", sinon.match.any); 326 | expect(game.state.graph.currentNodeId).to.equal("first"); 327 | }); 328 | }); 329 | 330 | context("when the output has finished", function() { 331 | it("should play the appropriate new content", function() { 332 | game.completePassage("0"); // TODO: This is ugly 333 | 334 | game.receiveInput("counter", 11); 335 | expect(callback).to.have.been.calledWith("Goodbye World!", sinon.match.any); 336 | }); 337 | }); 338 | }); 339 | 340 | describe("when the predicate has multiple conditions", function() { 341 | beforeEach(function() { 342 | game = new Game(` 343 | # node 344 | text: This doesn't matter 345 | -> nextNode: [ counter >= 10 and counter <= 15 ] 346 | 347 | # nextNode 348 | text: Hi 349 | `); 350 | 351 | callback = sinon.spy(); 352 | game.addOutput("text", callback); 353 | game.start(); 354 | game.completePassage("0"); // TODO 355 | }); 356 | 357 | 358 | it("shouldn't transition when only one condition is met", function() { 359 | game.receiveInput("counter", 9); 360 | expect(callback).not.to.have.been.calledWith("Hi", sinon.match.any); 361 | }); 362 | 363 | it("shouldn't transition when only the other condition is met", function() { 364 | game.receiveInput("counter", 16); 365 | expect(callback).not.to.have.been.calledWith("Hi", sinon.match.any); 366 | }); 367 | 368 | it("should transition when both conditions are met", function() { 369 | game.receiveInput("counter", 12); 370 | expect(callback).to.have.been.calledWith("Hi", sinon.match.any); 371 | }); 372 | }); 373 | }); 374 | 375 | context("when a choice is triggered by an in-passage variable set", function() { 376 | let game: Game, callback: any; 377 | beforeEach(function() { 378 | game = new Game(` 379 | # first 380 | text: Hi! 381 | set foo to 1 382 | -> second: [ foo is 1 ] 383 | 384 | # second 385 | text: Goodbye World! 386 | `); 387 | 388 | callback = sinon.spy(); 389 | game.addOutput("text", callback); 390 | 391 | game.start(); 392 | }); 393 | 394 | it("shouldn't change until the passage has ended", function() { 395 | expect(callback).not.to.have.been.calledWith("Goodbye World!", sinon.match.any); 396 | expect(game.state.graph.currentNodeId).to.equal("first"); 397 | }) 398 | it("should change after the passage has ended", function() { 399 | game.completePassage("0"); // TODO 400 | expect(callback).to.have.been.calledWith("Goodbye World!", sinon.match.any); 401 | expect(game.state.graph.currentNodeId).to.equal("second"); 402 | }); 403 | }) 404 | }); 405 | 406 | describe("triggering events from the bag", function() { 407 | context("when the game hasn't started yet", function() { 408 | let node, game, output: any; 409 | beforeEach(function() { 410 | game = new Game(` 411 | ## node 412 | [ foo <= 10 ] 413 | text: What do you want? 414 | text: Well let me tell you! 415 | `); 416 | 417 | output = sinon.spy(); 418 | game.addOutput("text", output); 419 | 420 | game.receiveInput("foo", 7); 421 | }); 422 | 423 | it("shouldn't do anything", function() { 424 | expect(output).not.to.have.been.calledOnce; 425 | }); 426 | }) 427 | 428 | describe("different ways to trigger an event", function() { 429 | context("when the game starts without a start", function() { 430 | let game, output: any; 431 | 432 | beforeEach(function() { 433 | game = new Game(` 434 | ## node 435 | track: other 436 | text: First 437 | `); 438 | 439 | output = sinon.spy(); 440 | game.addOutput("text", output); 441 | game.start(); 442 | }); 443 | 444 | it("should play a valid event node", function() { 445 | expect(output).to.have.been.calledWith("First", sinon.match.any, "other") 446 | }) 447 | }); 448 | 449 | context("triggered by an input change", function() { 450 | let game: Game, output: any; 451 | beforeEach(function() { 452 | game = new Game(` 453 | ## node 454 | [ foo <= 10 ] 455 | text: What do you want? 456 | text: Well let me tell you! 457 | `); 458 | 459 | output = sinon.spy(); 460 | game.addOutput("text", output); 461 | game.start(); 462 | 463 | game.receiveInput("foo", 7); 464 | }); 465 | 466 | it("should trigger that node", function() { 467 | expect(output).to.have.been.calledWith("What do you want?", sinon.match.any); 468 | }); 469 | 470 | it("should not trigger the second passage yet", function() { 471 | expect(output).not.to.have.been.calledWith("Well let me tell you!", sinon.match.any); 472 | }); 473 | 474 | describe("when the first passage is done", function() { 475 | it("should play the next passage", function() { 476 | game.completePassage("0"); 477 | expect(output).to.have.been.calledWith("Well let me tell you!", sinon.match.any); 478 | }); 479 | }); 480 | }); 481 | 482 | context("triggered by a push-button input", function() { 483 | let game: Game, output: any; 484 | beforeEach(function() { 485 | game = new Game(` 486 | ## node 487 | [ button ] 488 | text: What do you want? 489 | `); 490 | 491 | output = sinon.spy(); 492 | game.addOutput("text", output); 493 | game.start(); 494 | 495 | game.receiveMomentaryInput("button") 496 | }); 497 | 498 | it("should trigger that node", function() { 499 | expect(output).to.have.been.calledWith("What do you want?", sinon.match.any); 500 | }); 501 | 502 | it("should not change the global state", function() { 503 | expect(game.state.button).to.not.exist 504 | }); 505 | }) 506 | 507 | context("triggered by a graph node being completed", function() { 508 | let node, game: Game, output: any; 509 | beforeEach(function() { 510 | game = new Game(` 511 | # graphNode 512 | text: foobar 513 | 514 | ## bagNode 515 | [ graph.nodeComplete ] 516 | text: Hello 517 | `) 518 | 519 | output = sinon.spy(); 520 | game.addOutput("text", output); 521 | game.start(); 522 | }); 523 | 524 | it("should not trigger the node initially", function() { 525 | expect(output).not.to.have.been.calledWith("Hello", sinon.match.any); 526 | }); 527 | 528 | describe("when the graph node is complete", function() { 529 | it("should play the bag node", function() { 530 | game.completePassage("1"); // TODO, lol I don't know why this is 1 instead of 0 531 | expect(output).to.have.been.calledWith("Hello", sinon.match.any); 532 | }); 533 | }); 534 | }); 535 | 536 | context("triggered by reaching a specific graph node", function() { 537 | let node, game: Game, output: any; 538 | beforeEach(function() { 539 | game = new Game(` 540 | # firstGraphNode 541 | text: foo 542 | -> secondGraphNode 543 | 544 | # secondGraphNode 545 | text: bar 546 | 547 | ## bagNode 548 | [ graph.currentNodeId is "secondGraphNode" ] 549 | text: Hello 550 | `) 551 | 552 | 553 | output = sinon.spy(); 554 | game.addOutput("text", output); 555 | game.start(); 556 | }); 557 | 558 | it("should not trigger the node initially", function() { 559 | expect(output).not.to.have.been.calledWith("Hello", sinon.match.any); 560 | }); 561 | 562 | describe("when the target graph node has been completed", function() { 563 | it("should trigger the bag node", function() { 564 | game.completePassage("1"); // TODO 565 | expect(output).to.have.been.calledWith("Hello", sinon.match.any); 566 | }); 567 | }); 568 | }); 569 | }); 570 | 571 | describe("triggering the same node multiple times", function() { 572 | context("when the node should only trigger once", function() { 573 | let game: Game, output: any; 574 | beforeEach(function() { 575 | game = new Game(` 576 | ## node 577 | [ foo <= 10 ] 578 | text: Something 579 | `) 580 | 581 | output = sinon.spy(); 582 | game.addOutput("text", output); 583 | game.start(); 584 | }) 585 | 586 | it("shouldn't play a second time while it's still playing the first time", function() { 587 | game.receiveInput("foo", 7); 588 | game.receiveInput("foo", 7); 589 | 590 | expect(output).to.have.been.calledOnce; 591 | }) 592 | 593 | it("shouldn't play even after the first time has completed", function() { 594 | game.receiveInput("foo", 7); 595 | game.completePassage("0"); 596 | game.receiveInput("foo", 7); 597 | 598 | expect(output).to.have.been.calledOnce; 599 | }) 600 | }); 601 | 602 | context("when the node should trigger multiple times", function() { 603 | let game: Game, node, output: any; 604 | beforeEach(function() { 605 | game = new Game(` 606 | ## node 607 | [ foo <= 10 ] 608 | text: Something 609 | allowRepeats 610 | `) 611 | 612 | output = sinon.spy(); 613 | game.addOutput("text", output); 614 | game.start(); 615 | }) 616 | 617 | it("shouldn't play a second time while it's still playing the first time", function() { 618 | game.receiveInput("foo", 7); 619 | game.receiveInput("foo", 7); 620 | 621 | expect(output).to.have.been.calledOnce; 622 | }) 623 | 624 | it.skip("should allow playing a second time", function() { 625 | game.receiveInput("foo", 7); 626 | game.completePassage("0"); 627 | game.receiveInput("foo", 7); 628 | 629 | expect(output).to.have.been.calledTwice; 630 | }) 631 | }); 632 | }); 633 | 634 | context("when there are multiple valid nodes", function() { 635 | context("when they both belong to the default track", function() { 636 | let game, output: any; 637 | 638 | beforeEach(function() { 639 | game = new Game(` 640 | ## first 641 | text: First 642 | 643 | ## second 644 | text: Second 645 | `) 646 | 647 | output = sinon.spy(); 648 | game.addOutput("text", output); 649 | game.start(); 650 | }); 651 | 652 | it("should only play one of them at once", function() { 653 | expect(output).to.have.been.calledOnce; 654 | }) 655 | }); 656 | 657 | context("when they belong to the same track", function() { 658 | let game: Game, output: any; 659 | beforeEach(function() { 660 | game = new Game(` 661 | ## first 662 | track: primary 663 | text: First 664 | 665 | ## second 666 | track: primary 667 | text: Second 668 | `) 669 | 670 | 671 | output = sinon.spy(); 672 | game.addOutput("text", output); 673 | game.start(); 674 | }); 675 | 676 | it("should only play one of them at once", function() { 677 | expect(output).to.have.been.calledOnce; 678 | }) 679 | }); 680 | 681 | context("when they belong to different tracks", function() { 682 | let game, node1, node2, output: any; 683 | beforeEach(function() { 684 | game = new Game(` 685 | ## first 686 | track: primary 687 | text: Primary Track! 688 | 689 | ## second 690 | track: secondary 691 | text: Secondary Track! 692 | `) 693 | 694 | output = sinon.spy(); 695 | game.addOutput("text", output); 696 | game.start(); 697 | }); 698 | 699 | it("should play both of them", function() { 700 | expect(output).to.have.been.calledTwice; 701 | expect(output).to.have.been.calledWith("Primary Track!", sinon.match.any); 702 | expect(output).to.have.been.calledWith("Secondary Track!", sinon.match.any); 703 | }) 704 | }); 705 | }) 706 | }); 707 | 708 | describe("receiving input", function() { 709 | context("when the input variable is a keypath", function() { 710 | context("when the keypath exists", function() { 711 | let game: Game; 712 | beforeEach(function() { 713 | game = new Game({}) 714 | game.state.foo = {} 715 | game.receiveInput("foo.bar", "baz") 716 | }) 717 | 718 | it("should create the appropriate state", function() { 719 | expect(game.state.foo.bar).to.equal("baz") 720 | }) 721 | 722 | it("should not create the wrong variable", function() { 723 | expect(game.state["foo.bar"]).to.not.exist 724 | }) 725 | }) 726 | 727 | // TODO: This behavior should eventually match the previous test case 728 | context("when the keypath doesn't exist", function() { 729 | let game: Game; 730 | beforeEach(function() { 731 | game = new Game({}) 732 | game.receiveInput("foo.bar", "baz") 733 | }) 734 | 735 | it("should create nested state", function() { 736 | expect(game.state.foo.bar).to.equal("baz") 737 | }) 738 | 739 | it("should not create the wrong variable", function() { 740 | expect(game.state["foo.bar"]).to.not.exist 741 | }) 742 | }) 743 | }) 744 | 745 | context("when the input data is an object", function() { 746 | it("should set the object", function() { 747 | let game = new Game({}); 748 | game.receiveInput("foo", {"bar": "baz"}) 749 | expect(game.state.foo.bar).to.equal("baz") 750 | }) 751 | }) 752 | }) 753 | 754 | describe("observing variables", function() { 755 | let game: Game, output: any; 756 | 757 | context("when the thing being observed is just a variable", () => { 758 | context("changing the variable via input", () => { 759 | beforeEach(function() { 760 | game = new Game(` 761 | ## node 762 | text: Don't you watch! 763 | `); 764 | 765 | output = sinon.spy(); 766 | 767 | game.addObserver("pot", output); 768 | game.start() 769 | game.receiveInput("pot", "boiled"); 770 | }); 771 | 772 | it("should trigger when the value changes", () => { 773 | expect(output).to.have.been.calledWith("boiled") 774 | }) 775 | }) 776 | 777 | context("changing the variable within the story", () => { 778 | beforeEach(function() { 779 | game = new Game(` 780 | ## node 781 | set pot to "boiled" 782 | `); 783 | 784 | output = sinon.spy(); 785 | 786 | game.addObserver("pot", output); 787 | game.start() 788 | }); 789 | 790 | it("should trigger when the value changes", () => { 791 | expect(output).to.have.been.calledWith("boiled") 792 | }) 793 | }) 794 | }) 795 | 796 | context("when the thing being observed is a nested property", () => { 797 | context("changing the value via input", () => { 798 | beforeEach(function() { 799 | game = new Game(` 800 | ## node 801 | text: Don't you watch! 802 | `); 803 | 804 | output = sinon.spy(); 805 | 806 | game.addObserver("pot.waterTemp", output); 807 | game.start() 808 | game.receiveInput("pot.waterTemp", "boiled"); 809 | }); 810 | 811 | it("should trigger when the value changes", () => { 812 | expect(output).to.have.been.calledWith("boiled") 813 | }) 814 | 815 | it("should trigger on subsequent changes", () => { 816 | game.receiveInput("pot.waterTemp", "unboiled") 817 | expect(output).to.have.been.calledWith("unboiled") 818 | }) 819 | 820 | it("should trigger when undefined", () => { 821 | game.receiveInput("pot.waterTemp", undefined) 822 | expect(output).to.have.been.calledWith(undefined) 823 | }) 824 | 825 | context("when there are multiple observers", () => { 826 | let output2 = sinon.spy() 827 | beforeEach(() => { 828 | game.addObserver("pot.waterLevel", output) 829 | game.addObserver("pot.waterLevel", output2) 830 | }) 831 | 832 | it("should trigger both", () => { 833 | game.receiveInput("pot.waterLevel", "3 quarts") 834 | 835 | expect(output).to.have.been.calledWith("3 quarts") 836 | expect(output2).to.have.been.calledWith("3 quarts") 837 | }) 838 | 839 | context("when one is unobserved", () => { 840 | it("should only call the right one", () => { 841 | game.removeObserver("pot.waterLevel", output) 842 | game.receiveInput("pot.waterLevel", "3 quarts") 843 | 844 | expect(output).to.not.have.been.calledWith("3 quarts") 845 | expect(output2).to.have.been.calledWith("3 quarts") 846 | }) 847 | }) 848 | }) 849 | 850 | it("should not trigger after unobserving", () => { 851 | game.removeObserver("pot.waterTemp", output) 852 | game.receiveInput("pot.waterTemp", "unboiled") 853 | expect(output).to.not.have.been.calledWith("unboiled") 854 | }) 855 | }) 856 | 857 | context("changing the value within the story", () => { 858 | beforeEach(function() { 859 | game = new Game(` 860 | ## node 861 | set pot.waterTemp to "boiled" 862 | `); 863 | 864 | output = sinon.spy(); 865 | 866 | game.addObserver("pot.waterTemp", output); 867 | game.start() 868 | }); 869 | 870 | it("should trigger when the value changes", () => { 871 | expect(output).to.have.been.calledWith("boiled") 872 | }) 873 | 874 | it("should trigger on subsequent changes", () => { 875 | game = new Game(` 876 | ## node 877 | set pot.waterTemp to "boiled" 878 | set pot.waterTemp to "unboiled" 879 | `); 880 | 881 | output = sinon.spy(); 882 | 883 | game.addObserver("pot.waterTemp", output); 884 | game.start() 885 | 886 | expect(output).to.have.been.calledWith("boiled") 887 | expect(output).to.have.been.calledWith("unboiled") 888 | }) 889 | }) 890 | }) 891 | 892 | // TODO 893 | context.skip("when the thing being observed is a system variable", () => { 894 | beforeEach(function() { 895 | game = new Game(` 896 | ## node 897 | set pot.waterTemp to "boiled" 898 | `); 899 | 900 | output = sinon.spy(); 901 | 902 | game.addObserver("graph.currentNodeId", output); 903 | game.start() 904 | }); 905 | 906 | it("should trigger when the value changes", () => { 907 | expect(output).to.have.been.calledWith("node") 908 | }) 909 | }) 910 | }) 911 | 912 | // TODO: Find somewhere else for these to live? 913 | describe("receiveDispatch", function() { 914 | // TODO: Backfill this out 915 | describe("MAKE_GRAPH_CHOICE",function() { 916 | let choice1: Parser.Choice, choice2: Parser.Choice, game: Game; 917 | beforeEach(function() { 918 | choice1 = { nodeId: "3" } 919 | choice2 = { nodeId: "4" } 920 | game = new Game({ 921 | graph: { 922 | nodes: { 923 | 2: {nodeId: "2"}, 924 | 3: {nodeId: "3", "passages":[]}, 925 | 4:{ nodeId: "4", "passages":[]} 926 | }}}) 927 | 928 | game.state.graph.currentNodeId = "2"; 929 | game.receiveDispatch(Actions.MAKE_GRAPH_CHOICE, choice1); 930 | game.receiveDispatch(Actions.MAKE_GRAPH_CHOICE, choice2); 931 | }); 932 | 933 | it("should update the previousChoice with the most recent choice", function() { 934 | expect(game.state.graph.previousChoice).to.eql(choice2); 935 | }); 936 | 937 | it("should store the full choice history in reverse-chronological order", function() { 938 | expect(game.state.graph.choiceHistory).to.eql([choice2, choice1]); 939 | }); 940 | 941 | it("should store the previous nodeId", function() { 942 | expect(game.state.graph.previousNodeId).to.equal("3"); 943 | }); 944 | 945 | it("should store the full node history in reverse-chronological order", function() { 946 | expect(game.state.graph.nodeHistory).to.eql(["3", "2"]); 947 | }); 948 | }); 949 | 950 | // TODO: 951 | // COMPLETE_BAG_NODE now relies on more of the state graph than is being exposed in this tiny harness. 952 | // I could hack it to work now, but I need to think more about the value and structure of these unit-level tests in general 953 | describe.skip("COMPLETE_BAG_NODE", function() { 954 | it("should remove the node from the list of active bag passages", function() { 955 | const game = new Game({}); 956 | game.state.bag.activePassageIndexes["1"] = 0; 957 | game.receiveDispatch(Actions.COMPLETE_BAG_NODE, "1"); 958 | 959 | expect(game.state.bag.activePassageIndexes["1"]).to.be.undefined; 960 | }); 961 | 962 | it("should trigger a sweep of new bag nodes", function() { 963 | // TODO: I'm not sure this is a valuable test! 964 | const game = new Game({}); 965 | sinon.stub(game.story.bag, "checkNodes"); 966 | game.receiveDispatch(Actions.COMPLETE_BAG_NODE, "1"); 967 | 968 | expect(game.story.bag.checkNodes).to.have.been.calledWith(game.state); 969 | }); 970 | 971 | context("when the node has never been visited before", function() { 972 | it("should add it to the bag node history", function() { 973 | const game = new Game({}); 974 | expect(game.state.bag.nodeHistory["1"]).to.be.undefined; 975 | game.receiveDispatch(Actions.COMPLETE_BAG_NODE, "1"); 976 | expect(game.state.bag.nodeHistory["1"]).to.equal(1); 977 | }) 978 | }) 979 | 980 | context("when the node has been visited before", function() { 981 | it("should increment its node history", function() { 982 | const game = new Game({}); 983 | game.receiveDispatch(Actions.COMPLETE_BAG_NODE, "1"); 984 | game.receiveDispatch(Actions.COMPLETE_BAG_NODE, "1"); 985 | expect(game.state.bag.nodeHistory["1"]).to.equal(2); 986 | }); 987 | }) 988 | }); 989 | 990 | describe("OUTPUT", function() { 991 | describe("selecting outputs", function() { 992 | let textOutput1: any, textOutput2: any, audioOutput: any, game, passage; 993 | beforeEach(function() { 994 | textOutput1 = sinon.spy(); 995 | textOutput2 = sinon.spy(); 996 | audioOutput = sinon.spy(); 997 | 998 | game = new Game({}); 999 | game.addOutput("text", textOutput1); 1000 | game.addOutput("text", textOutput2); 1001 | game.addOutput("audio", audioOutput); 1002 | 1003 | passage = { 1004 | "passageId": "5", 1005 | "content": "Hello World!", 1006 | "type": "text" 1007 | }; 1008 | 1009 | game.receiveDispatch(Actions.OUTPUT, passage) 1010 | }); 1011 | it("should send output to all registered outputs of that type", function() { 1012 | expect(textOutput1).to.have.been.calledWith("Hello World!", sinon.match.any); 1013 | expect(textOutput2).to.have.been.calledWith("Hello World!", sinon.match.any); 1014 | }); 1015 | 1016 | it("should only send output to outputs of the same type", function() { 1017 | expect(audioOutput).not.to.have.been.calledWith("Hello World!", sinon.match.any); 1018 | }); 1019 | }); 1020 | 1021 | it("should evaluate all embedded variables", function() { 1022 | const game = new Game({}); 1023 | const output = sinon.spy() 1024 | 1025 | const passage = { 1026 | "passageId": "5", 1027 | "content": "Hello {yourName}, it's {myName}!", 1028 | "type": "text" 1029 | }; 1030 | 1031 | game.state.yourName = "Stanley"; 1032 | game.state.myName = "Ollie"; 1033 | game.addOutput("text", output); 1034 | 1035 | game.receiveDispatch(Actions.OUTPUT, passage); 1036 | 1037 | expect(output).to.have.been.calledWith("Hello Stanley, it's Ollie!", sinon.match.any); 1038 | }); 1039 | 1040 | it("should allow nested keypaths in variables", function() { 1041 | const game = new Game({}); 1042 | const output = sinon.spy() 1043 | 1044 | const passage = { 1045 | "passageId": "5", 1046 | "content": "You're in node {graph.currentNodeId}!", 1047 | "type": "text" 1048 | }; 1049 | 1050 | game.state.graph.currentNodeId = "1"; 1051 | game.addOutput("text", output); 1052 | 1053 | game.receiveDispatch(Actions.OUTPUT, passage); 1054 | 1055 | expect(output).to.have.been.calledWith("You're in node 1!", sinon.match.any); 1056 | }); 1057 | }); 1058 | }); 1059 | 1060 | -------------------------------------------------------------------------------- /tests/nodeBagTests.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | import * as sinonChai from 'sinon-chai' 3 | import * as sinon from 'sinon' 4 | 5 | chai.use(sinonChai); 6 | const expect = chai.expect 7 | 8 | import { Bag } from '../src/nodeBag' 9 | import * as Actions from '../src/gameActions' 10 | import { State } from '../src/state' 11 | 12 | import * as Parser from 'storyboard-lang' 13 | 14 | describe("filtering nodes", function() { 15 | context("when a track is already active", () => { 16 | it("should ignore just that track", () => { 17 | const story = Parser.parseString(` 18 | ## first 19 | [ foo < 9 ] 20 | text: Hi 21 | 22 | ## second 23 | [ foo <= 10 ] 24 | text: Hello 25 | 26 | ## third 27 | [ foo < 100 ] 28 | track: otherTrack 29 | text: La dee dah, off in my own world 30 | `) as Parser.Story 31 | 32 | const dispatch = sinon.spy() 33 | const bag = new Bag(story.bag!, dispatch) 34 | 35 | const state = new State() 36 | state.bag.activeTracks.default = "first" 37 | 38 | bag.checkNodes(state); 39 | 40 | expect(dispatch).to.have.been.calledWith(Actions.TRIGGERED_BAG_NODES, {"otherTrack": bag.nodes.third}) 41 | }) 42 | }) 43 | 44 | context("when some nodes match the predicate but others don't", function() { 45 | it("should only return the nodes that match", function() { 46 | const story = Parser.parseString(` 47 | ## first 48 | [ foo < 9 ] 49 | text: first 50 | 51 | ## second 52 | [ foo <= 0 ] 53 | text: second 54 | `) as Parser.Story 55 | 56 | const dispatch = sinon.spy() 57 | 58 | const bag = new Bag(story.bag, dispatch); 59 | const state = new State() 60 | state.foo = "5" 61 | 62 | bag.checkNodes(state); 63 | 64 | expect(dispatch).to.have.been.calledWith(Actions.TRIGGERED_BAG_NODES, {"default": bag.nodes.first}) 65 | }) 66 | }); 67 | }); -------------------------------------------------------------------------------- /tests/nodeGraphTests.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | import * as sinonChai from 'sinon-chai' 3 | import { spy } from 'sinon' 4 | 5 | chai.use(sinonChai); 6 | const expect = chai.expect 7 | 8 | import Graph from '../src/nodeGraph' 9 | import * as Actions from '../src/gameActions' 10 | 11 | var graph; 12 | 13 | describe("completePassage", function() { 14 | context("when it is the last passage in the node", function() { 15 | it("should transition to the next node", function() { 16 | 17 | }); 18 | }); 19 | 20 | context("when it isn't the last passage", function() { 21 | it("should transition to the next passage", function() { 22 | 23 | }); 24 | }); 25 | }); 26 | 27 | describe("checkChoiceTransitions", function() { 28 | context("when the node isn't complete", function() { 29 | it("should do nothing, even if a choice is ready", function() { 30 | 31 | }); 32 | }); 33 | 34 | context("when the node is complete", function() { 35 | it("should change nodes when the conditions are met", function() { 36 | 37 | }); 38 | 39 | it("should not change nodes when the conditions aren't met", function() { 40 | 41 | }); 42 | }); 43 | }); 44 | 45 | describe("playCurrentPassage", function() { 46 | it("should output with the correct passage", function() { 47 | 48 | }); 49 | }); -------------------------------------------------------------------------------- /tests/nodeTests.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | import * as sinonChai from 'sinon-chai' 3 | import * as sinon from 'sinon' 4 | 5 | chai.use(sinonChai); 6 | const expect = chai.expect 7 | 8 | import { Game } from '../src/game' 9 | import { State } from '../src/state' 10 | import * as Actions from '../src/gameActions' 11 | import keyPathify from '../src/keyPathify' 12 | 13 | describe("setting variables", function() { 14 | context("within the graph", function() { 15 | let game: Game, callback: any; 16 | beforeEach(function() { 17 | const story = ` 18 | # node 19 | set foo to bar 20 | set baz to graph.currentNodeId 21 | set a.b to "c" 22 | ` 23 | game = new Game(story) 24 | 25 | callback = sinon.spy(); 26 | game.addOutput("text", callback); 27 | 28 | game.state.rngSeed = "knownSeed" 29 | game.start(); 30 | }) 31 | 32 | afterEach(function() { 33 | const state = new State() 34 | state.rngSeed = "erase" 35 | keyPathify(undefined, state) 36 | }) 37 | 38 | it("should set variable literals", function() { 39 | expect(game.state.foo).to.equal("bar") 40 | }) 41 | 42 | it("should evaluate values that are keypaths", function() { 43 | expect(game.state.baz).to.equal("node") 44 | }) 45 | 46 | it("should evaluate keys that are keypaths", () => { 47 | expect(game.state.a.b).to.equal("c") 48 | }) 49 | 50 | context("functionality not yet in the lang", () => { 51 | beforeEach(function() { 52 | game = new Game({ 53 | "graph": { 54 | "start": "node", 55 | "nodes": { 56 | "node": { 57 | "nodeId": "node", 58 | "passages": [ 59 | { 60 | "passageId": "1", 61 | "set": { 62 | "random1": { randInt: [0, 6] }, 63 | "random2": { randInt: [0, 6] } 64 | } 65 | } 66 | ] 67 | } 68 | } 69 | } 70 | }); 71 | 72 | callback = sinon.spy(); 73 | game.addOutput("text", callback); 74 | 75 | game.state.rngSeed = "knownSeed" 76 | game.start(); 77 | }) 78 | 79 | it("should set random numbers", function() { 80 | expect(game.state.random1).to.equal(4) 81 | expect(game.state.random2).to.equal(1) 82 | }) 83 | }) 84 | 85 | context("when the passage also has content in it", function() { 86 | beforeEach(function() { 87 | const story = ` 88 | # node 89 | set foo to bar 90 | set baz to graph.currentNodeId 91 | text: Hi there! 92 | ` 93 | 94 | game = new Game(story) 95 | console.log(JSON.stringify(game.story, null, 3)) 96 | callback = sinon.spy(); 97 | game.addOutput("text", callback); 98 | 99 | game.start(); 100 | }) 101 | 102 | it("should output the content", function() { 103 | expect(callback).to.have.been.calledWith("Hi there!", sinon.match.any, "default") 104 | }) 105 | 106 | it("should set the variable", function() { 107 | expect(game.state.foo).to.equal("bar") 108 | }) 109 | 110 | it("should set keypath variables", function() { 111 | expect(game.state.baz).to.equal("node") 112 | }) 113 | }) 114 | }) 115 | 116 | context("within the bag", function() { 117 | let game: Game, callback: any; 118 | beforeEach(function() { 119 | const story = ` 120 | ## node 121 | set foo to bar 122 | set baz to bag.activePassageIndexes.node 123 | text: Hi there! 124 | ` 125 | game = new Game(story) 126 | 127 | const callback = sinon.spy(); 128 | game.addOutput("text", callback); 129 | 130 | game.state.rngSeed = "knownSeed" 131 | game.start(); 132 | }) 133 | 134 | afterEach(function() { 135 | const state = new State() 136 | state.rngSeed = "erase" 137 | keyPathify(undefined, state) 138 | }) 139 | 140 | it("should set variable literals", function() { 141 | expect(game.state.foo).to.equal("bar") 142 | }) 143 | 144 | it("should set keypath variables", function() { 145 | expect(game.state.baz).to.equal(1) 146 | }) 147 | 148 | context("functionality not yet in the lang", () => { 149 | beforeEach(function() { 150 | game = new Game({ 151 | "bag": { 152 | "node": { 153 | "nodeId": "node", 154 | "passages": [ 155 | { 156 | "passageId": "1", 157 | "set": { 158 | "foo": "bar", 159 | "baz": "bag.activePassageIndexes.node", 160 | "random1": { "randInt": [0, 6] }, 161 | "random2": { "randInt": [0, 6] } 162 | } 163 | } 164 | ] 165 | } 166 | } 167 | }); 168 | 169 | const callback = sinon.spy(); 170 | game.addOutput("text", callback); 171 | 172 | game.state.rngSeed = "knownSeed" 173 | game.start(); 174 | }) 175 | 176 | it("should set random numbers", function() { 177 | expect(game.state.random1).to.equal(4) 178 | expect(game.state.random2).to.equal(1) 179 | }) 180 | }) 181 | }) 182 | }); -------------------------------------------------------------------------------- /tests/passageTests.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | import * as sinonChai from 'sinon-chai' 3 | import * as sinon from 'sinon' 4 | 5 | chai.use(sinonChai); 6 | const expect = chai.expect 7 | 8 | import { Game } from '../src/game' 9 | 10 | describe("'wait' passages", function() { 11 | let game, callback: any; 12 | beforeEach(function() { 13 | const story = ` 14 | # theNode 15 | wait: 5 16 | text: You made it! 17 | ` 18 | game = new Game(story) 19 | 20 | callback = sinon.spy(); 21 | game.addOutput("text", callback); 22 | 23 | game.start(); 24 | }); 25 | 26 | context("immediately", function() { 27 | it("should not play the next passage yet", function () { 28 | expect(callback).not.to.have.been.calledWith("You made it!", sinon.match.any, "default") 29 | }) 30 | }) 31 | 32 | context("after enough time has passed", function () { 33 | it("should play the next passage", function(done) { 34 | setTimeout(function() { 35 | expect(callback).to.have.been.calledWith("You made it!", sinon.match.any, "default"); 36 | done(); 37 | }, 5); 38 | }); 39 | }); 40 | }); 41 | 42 | // TODO: It is bonkers this logic is reimplemented in both the bag and graph 43 | describe("passages with predicates", () => { 44 | context("graph", () => { 45 | let game: Game, callback: any; 46 | beforeEach(function() { 47 | const story = ` 48 | # theNode 49 | set sawDefault to true 50 | [unless sawDefault] 51 | set sawFalseNode to true 52 | [sawDefault] 53 | set sawTrueNode to true 54 | ` 55 | game = new Game(story) 56 | console.log(JSON.stringify(game.story.graph, null, 2)) 57 | game.start(); 58 | }); 59 | 60 | it("should play normal predicate-less nodes", () => { 61 | expect(game.state.sawDefault).to.equal(true) 62 | }) 63 | 64 | it("should play true predicates", function () { 65 | expect(game.state.sawTrueNode).to.equal(true) 66 | }) 67 | 68 | it("should skip false predicates", function () { 69 | expect(game.state.sawFalseNode).to.not.exist 70 | }) 71 | 72 | // TODO: unimplemented functionality! 73 | it.skip("should allow checking raw values instead of state", () => { 74 | const story = ` 75 | # theNode 76 | set sawDefault to true 77 | [false] 78 | set sawFalseNode to true 79 | [true] 80 | set sawTrueNode to true 81 | ` 82 | game = new Game(story) 83 | console.log(JSON.stringify(game.story.graph, null, 2)) 84 | game.start(); 85 | 86 | expect(game.state.sawDefault).to.equal(true) 87 | expect(game.state.sawTrueNode).to.equal(true) 88 | expect(game.state.sawFalseNode).to.equal(false) 89 | }) 90 | }) 91 | 92 | context("bag", () => { 93 | let game: Game, callback: any; 94 | beforeEach(function() { 95 | const story = ` 96 | ## theNode 97 | [unless somethingFalse exists] 98 | set sawDefault to true 99 | [unless sawDefault] 100 | set sawFalseNode to true 101 | [sawDefault] 102 | set sawTrueNode to true 103 | ` 104 | game = new Game(story) 105 | console.log(JSON.stringify(game.story.graph, null, 2)) 106 | game.start(); 107 | }); 108 | 109 | it("should play normal predicate-less nodes", () => { 110 | expect(game.state.sawDefault).to.equal(true) 111 | }) 112 | 113 | it("should play true predicates", function () { 114 | expect(game.state.sawTrueNode).to.equal(true) 115 | }) 116 | 117 | it("should skip false predicates", function () { 118 | expect(game.state.sawFalseNode).to.not.exist 119 | }) 120 | 121 | // TODO: unimplemented functionality! 122 | it.skip("should allow checking raw values instead of state", () => { 123 | const story = ` 124 | ## theNode 125 | set sawDefault to true 126 | [false] 127 | set sawFalseNode to true 128 | [true] 129 | set sawTrueNode to true 130 | ` 131 | game = new Game(story) 132 | console.log(JSON.stringify(game.story.graph, null, 2)) 133 | game.start(); 134 | 135 | expect(game.state.sawDefault).to.equal(true) 136 | expect(game.state.sawTrueNode).to.equal(true) 137 | expect(game.state.sawFalseNode).to.equal(false) 138 | }) 139 | }) 140 | }) -------------------------------------------------------------------------------- /tests/predicateTests.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | import * as sinonChai from 'sinon-chai' 3 | import { spy } from 'sinon' 4 | 5 | chai.use(sinonChai); 6 | const expect = chai.expect 7 | 8 | import * as Parser from 'storyboard-lang' 9 | import checkPredicate from '../src/predicate' 10 | import keyPathify from '../src/keyPathify' 11 | 12 | // TODO: We have no tests for [unless foo] or [if not foo] (parsed as { not: {...predicate }}) 13 | // passageTests includes coverage for this, but it should be explicitly covered here 14 | 15 | describe("predicates", () => { 16 | let predicate: Parser.Predicate; 17 | 18 | describe("predicate types", function() { 19 | describe("lte", function() { 20 | beforeEach(function() { 21 | predicate = {"foo": {"lte": 5}} 22 | }); 23 | 24 | it("should not fire for values greater than", function() { 25 | const result = checkPredicate(predicate, {"foo": 10}); 26 | expect(result).to.be.false; 27 | }); 28 | 29 | it("should fire for values less than", function() { 30 | const result = checkPredicate(predicate, {"foo": 4}); 31 | expect(result).to.be.true; 32 | }); 33 | 34 | it("should fire for values equal to", function() { 35 | const result = checkPredicate(predicate, {"foo": 5}); 36 | expect(result).to.be.true; 37 | }); 38 | }); 39 | 40 | describe("gte", function() { 41 | beforeEach(function() { 42 | predicate = {"foo": {"gte": 5}} 43 | }); 44 | 45 | it("should not fire for values less than", function() { 46 | const result = checkPredicate(predicate, {"foo": 4}); 47 | expect(result).to.be.false; 48 | }); 49 | 50 | it("should fire for values greater than", function() { 51 | const result = checkPredicate(predicate, {"foo": 6}); 52 | expect(result).to.be.true; 53 | }); 54 | 55 | it("should fire for values equal to", function() { 56 | const result = checkPredicate(predicate, {"foo": 5}); 57 | expect(result).to.be.true; 58 | }); 59 | }); 60 | 61 | describe("eq", function() { 62 | beforeEach(function() { 63 | predicate = {"foo": {"eq": 5}} 64 | }); 65 | 66 | context("numbers", function() { 67 | it("should fire for equal values", function() { 68 | const result = checkPredicate(predicate, {"foo": 5}); 69 | expect(result).to.be.true 70 | }) 71 | 72 | it("should not fire for greater values", function() { 73 | const result = checkPredicate(predicate, {"foo": 6}); 74 | expect(result).to.be.false 75 | }) 76 | 77 | it("should not fire for lesser values", function() { 78 | const result = checkPredicate(predicate, {"foo": 4}); 79 | expect(result).to.be.false 80 | }) 81 | }) 82 | 83 | context("strings", function() { 84 | it("should return true when two strings are equal", function() { 85 | const predicate = {"foo": {"eq": "bar"}}; 86 | const result = checkPredicate(predicate, {foo: "bar"}) 87 | expect(result).to.be.true 88 | }) 89 | 90 | it("should return false when two strings are not equal", function() { 91 | const predicate = {"foo": {"eq": "bar"}}; 92 | const result = checkPredicate(predicate, {foo: "baz"}) 93 | expect(result).to.be.false 94 | }) 95 | 96 | it("should return false when the input value is a substring", function() { 97 | const predicate = {"foo": {"eq": "ba"}}; 98 | const result = checkPredicate(predicate, {foo: "bar"}) 99 | expect(result).to.be.false 100 | }) 101 | 102 | it("should return false when the state value is a subtring", function() { 103 | const predicate = {"foo": {"eq": "bar"}}; 104 | const result = checkPredicate(predicate, {foo: "ba"}) 105 | expect(result).to.be.false 106 | }) 107 | 108 | context("when the equality value is a valid keypath", () => { 109 | it("should return true if the keypath value matches", () => { 110 | const predicate = {"a": {"eq": "b"}} 111 | const state = {"a": "the answer", "b": "the answer"} 112 | const result = checkPredicate(predicate, state) 113 | expect(result).to.be.true 114 | }) 115 | 116 | it("should return true when matching the exact string literal, not keypath-d", () => { 117 | const predicate = {"a": {"eq": "exact match"}} 118 | const state = {"a": "exact match", "exact match": "not it"} 119 | const result = checkPredicate(predicate, state) 120 | expect(result).to.be.true 121 | }) 122 | }) 123 | }) 124 | }) 125 | 126 | describe("exists", function() { 127 | describe("when asserting an object should exist", function() { 128 | beforeEach(function() { 129 | predicate = {"foo": {"exists": true}}; 130 | }); 131 | 132 | it("should return true if the object exists", function() { 133 | const result = checkPredicate(predicate, {"foo": 5}) 134 | expect(result).to.be.true; 135 | }); 136 | 137 | it("should return false if the object is undefined", function() { 138 | const result = checkPredicate(predicate, {"foo": undefined}) 139 | expect(result).to.be.false; 140 | 141 | }); 142 | 143 | it("should return false if the object key doesn't exist", function() { 144 | const result = checkPredicate(predicate, {}) 145 | expect(result).to.be.false; 146 | }); 147 | }); 148 | 149 | describe("when asserting an object shouldn't exist", function() { 150 | beforeEach(function() { 151 | predicate = {"foo": {"exists": false}}; 152 | }); 153 | 154 | it("should return false if the object exists", function() { 155 | const result = checkPredicate(predicate, {"foo": 5}) 156 | expect(result).to.be.false; 157 | }); 158 | 159 | it("should return true if the object is undefined", function() { 160 | const result = checkPredicate(predicate, {"foo": undefined}) 161 | expect(result).to.be.true; 162 | 163 | }); 164 | 165 | it("should return true if the object key doesn't exist", function() { 166 | const result = checkPredicate(predicate, {}) 167 | expect(result).to.be.true; 168 | }); 169 | }); 170 | }); 171 | }); 172 | 173 | describe("combining multiple conditions", function() { 174 | describe("using an (implicit) AND", function() { 175 | beforeEach(function() { 176 | predicate = {"foo": {"lte": 10, "gte": 5}} 177 | }); 178 | 179 | it("should return true when both are true", function() { 180 | const result = checkPredicate(predicate, {"foo": 7}); 181 | expect(result).to.be.true; 182 | }); 183 | 184 | it("should not return true when only one is true", function() { 185 | const result1 = checkPredicate(predicate, {"foo": 4}); 186 | expect(result1).to.be.false; 187 | 188 | const result2 = checkPredicate(predicate, {"foo": 11}); 189 | expect(result2).to.be.false; 190 | }); 191 | }) 192 | 193 | describe("using an explicit AND", () => { 194 | context("When used at the top level", () => { 195 | beforeEach(function() { 196 | predicate = {"and": [ 197 | {"foo": {"gte": 3}}, 198 | {"foo": {"lte": 5}} 199 | ]} 200 | }) 201 | 202 | it("should not trigger when only the first condition is met", function() { 203 | const result = checkPredicate(predicate, {foo: 6}) 204 | expect(result).to.be.false; 205 | }); 206 | 207 | it("should not trigger when the only second condition is met", function() { 208 | const result = checkPredicate(predicate, {foo: 2}) 209 | expect(result).to.be.false; 210 | }); 211 | 212 | it("should fire when neither condition is met", function() { 213 | const result = checkPredicate(predicate, {foo: 4}) 214 | expect(result).to.be.true; 215 | }); 216 | }) 217 | }) 218 | 219 | describe("using an explicit OR", function() { 220 | context("when used at the top level of the predicate", function() { 221 | beforeEach(function() { 222 | predicate = {"or": [ 223 | {"foo": {"eq": 3}}, 224 | {"foo": {"eq": 5}} 225 | ]} 226 | }) 227 | 228 | it("should trigger when the first condition is met", function() { 229 | const result = checkPredicate(predicate, {foo: 3}) 230 | expect(result).to.be.true; 231 | }); 232 | 233 | it("should trigger when the second condition is met", function() { 234 | const result = checkPredicate(predicate, {foo: 5}) 235 | expect(result).to.be.true; 236 | }); 237 | 238 | it("should not fire when neither condition is met", function() { 239 | const result = checkPredicate(predicate, {foo: 4}) 240 | expect(result).to.be.false; 241 | }); 242 | }) 243 | 244 | // TODO: This behavior is deprecated? 245 | context("within a predicate key", function() { 246 | beforeEach(function() { 247 | predicate = {"foo": {"or": [ 248 | {"eq": 3}, 249 | {"eq": 5} 250 | ]}} 251 | }) 252 | 253 | it("should trigger when the first condition is met", function() { 254 | const result = checkPredicate(predicate, {foo: 3}) 255 | expect(result).to.be.true; 256 | }); 257 | 258 | it("should trigger when the second condition is met", function() { 259 | const result = checkPredicate(predicate, {foo: 5}) 260 | expect(result).to.be.true; 261 | }); 262 | 263 | it("should not fire when neither condition is met", function() { 264 | const result = checkPredicate(predicate, {foo: 4}) 265 | expect(result).to.be.false; 266 | }); 267 | }) 268 | }) 269 | 270 | }); 271 | 272 | describe("combining multiple variables", function() { 273 | beforeEach(function() { 274 | predicate = {"foo": {"lte": 10}, 275 | "bar": {"gte": 5}}; 276 | }); 277 | 278 | it("should return true when both are true", function() { 279 | const result = checkPredicate(predicate, {"foo": 7, "bar": 7}); 280 | expect(result).to.be.true; 281 | }); 282 | 283 | it("should not return true when only one is true", function() { 284 | const result1 = checkPredicate(predicate, {"foo": 4, "bar": 4}); 285 | expect(result1).to.be.false; 286 | 287 | const result2 = checkPredicate(predicate, {"foo": 11, "bar": 11}); 288 | expect(result2).to.be.false; 289 | }); 290 | }); 291 | 292 | describe("random numbers", function() { 293 | context("when the value is a random integer", function() { 294 | let result1: boolean, result2: boolean, result3: boolean; 295 | beforeEach(function() { 296 | const predicate = {"foo": { 297 | "eq": { "randInt": [0, 6] } 298 | }} 299 | 300 | result1 = checkPredicate(predicate, {foo: 4, rngSeed: "knownSeed"}) 301 | result2 = checkPredicate(predicate, {foo: 1, rngSeed: "knownSeed"}) 302 | result3 = checkPredicate(predicate, {foo: 0, rngSeed: "knownSeed"}) 303 | }) 304 | 305 | afterEach(function() { 306 | keyPathify(undefined, {rngSeed: "erase"}) 307 | }) 308 | 309 | it.skip("should compare against a seeded random number", function() { 310 | expect(result1).to.be.true 311 | expect(result2).to.be.true 312 | expect(result3).to.be.true 313 | }) 314 | }) 315 | }) 316 | 317 | describe("keypath predicates", function() { 318 | describe("checking the value of a keypath as input", function() { 319 | context("when the value matches", function() { 320 | it("should match the predicate", function() { 321 | const predicate = {"foo.bar": {"lte": 10, "gte": 0}}; 322 | const state = {foo: {bar: 5}} 323 | const result = checkPredicate(predicate, state); 324 | expect(result).to.be.true; 325 | }); 326 | }); 327 | 328 | context("when the predicate is not met", function() { 329 | it("should not be a match", function() { 330 | const predicate = {"foo.bar": {"exists": false}}; 331 | const state = {foo: {bar: 5}} 332 | const result = checkPredicate(predicate, state); 333 | expect(result).to.be.false; 334 | }); 335 | }); 336 | }) 337 | 338 | describe("checking a value against a keypath value", function() { 339 | context("when the value matches", function() { 340 | it("should match the predicate", function() { 341 | const predicate = {"foo": {"lte": "bar.baz", "gte": "bar.baz"}}; 342 | const state = { 343 | foo: "hello", 344 | bar: { baz: "hello" } 345 | } 346 | const result = checkPredicate(predicate, state); 347 | expect(result).to.be.true; 348 | }); 349 | }); 350 | 351 | context("when the predicate is not met", function() { 352 | it("should not be a match", function() { 353 | const predicate = {"foo": {"lte": "bar.baz"}}; 354 | const state = { 355 | foo: 10, 356 | bar: { baz: 5 } 357 | } 358 | const result = checkPredicate(predicate, state); 359 | expect(result).to.be.false; 360 | }); 361 | }); 362 | }); 363 | }); 364 | }) -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "noImplicitAny": true, 7 | "strictNullChecks": true, 8 | "moduleResolution": "node", 9 | "allowSyntheticDefaultImports": true, 10 | "outDir": "types" 11 | }, 12 | "include": ["src/**/*"] 13 | } 14 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: './src/game.ts', 5 | module: { 6 | rules: [ 7 | { 8 | test: /\.ts$/, 9 | use: 'ts-loader', 10 | exclude: /node_modules/ 11 | } 12 | ] 13 | }, 14 | resolve: { 15 | extensions: [ '.ts', '.js' ] 16 | }, 17 | devtool: 'inline-source-map', 18 | output: { 19 | filename: 'bundle.js', 20 | path: path.resolve(__dirname, 'dist'), 21 | library: "storyboard", 22 | libraryTarget: 'umd', 23 | umdNamedDefine: true 24 | }, 25 | node: { 26 | crypto: 'empty' 27 | } 28 | }; --------------------------------------------------------------------------------