├── .eslintignore ├── .eslintrc ├── .gitignore ├── LICENSE.txt ├── README.md ├── __mocks__ ├── persistent.js └── state.js ├── __tests__ ├── components │ ├── game │ │ ├── control.test.js │ │ ├── index.test.js │ │ └── sessions.test.js │ ├── ink.test.js │ ├── saves.test.js │ ├── settings.test.js │ └── sound.test.js ├── index.test.js └── utils │ ├── config.test.js │ ├── emitter.test.js │ ├── hashcode.test.js │ ├── interfaces.test.js │ ├── scene-processors.test.js │ └── tags.test.js ├── babel.config.js ├── jest.config.js ├── package-lock.json ├── package.json └── src ├── components ├── game │ ├── control.js │ ├── index.js │ └── sessions.js ├── ink.js ├── saves.js ├── settings.js └── sound.js ├── index.js └── utils ├── config.js ├── emitter.js ├── hashcode.js ├── interfaces.js ├── scene-processors.js ├── tags.js └── to-array.js /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "airbnb-base" 4 | ], 5 | "parser": "@babel/eslint-parser", 6 | "rules": { 7 | "comma-dangle": ["error", "never"], 8 | "indent": ["error", 2], 9 | "no-use-before-define": ["error", { "functions": false, "classes": false }], 10 | "arrow-parens": ["error", "always"], 11 | "object-curly-spacing": ["error", "always"], 12 | "object-curly-newline": ["off"], 13 | "function-paren-newline": ["off"], 14 | "max-len": ["error", 120], 15 | "no-param-reassign": ["error", { "props": false }], 16 | "no-multiple-empty-lines": ["off"], 17 | "import/prefer-default-export": ["off"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /coverage/ 3 | /node_modules/ 4 | .eslintcache 5 | npm-debug.* 6 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2023 Serhii "techniX" Mozhaiskyi 4 | 5 | Permission is hereby granted, free of charge, to any person 6 | obtaining a copy of this software and associated documentation 7 | files (the "Software"), to deal in the Software without 8 | restriction, including without limitation the rights to use, 9 | copy, modify, merge, publish, distribute, sublicense, 10 | and/or sell copies of the Software, and to permit persons 11 | to whom the Software is furnished to do so, subject to the 12 | following conditions: 13 | 14 | The above copyright notice and this permission notice 15 | shall be included in all copies or substantial portions 16 | of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE 21 | AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 25 | OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Atrament 2 | 3 | `atrament-core` is a framework for choice-based games, built around `inkjs`. 4 | 5 | If you need a ready-to-use library for web applications, check out [atrament-web](https://github.com/technix/atrament-web). 6 | 7 | If you are looking for an example of a web application based on Atrament, check out [atrament-web-ui](https://github.com/technix/atrament-web-ui). 8 | 9 | ## Features 10 | 11 | - Implements game flow: loading Ink story, getting text content, making choices 12 | - Manages global application settings 13 | - Parses tags, and handles some of them (mostly compatible with Inky) 14 | - Auto-observe variables defined with 'observe' global tag 15 | - Manages sound and music via knot tags 16 | - Manages autosaves, checkpoints, and named saves for every game 17 | - Music state is saved and restored along with game state 18 | - All changes affect the internal state 19 | 20 | 21 | ## Installation 22 | 23 | ```npm install @atrament/core``` 24 | 25 | ## Tags handled by Atrament 26 | 27 | ### Global tags 28 | 29 | | Tag | Description | 30 | | :-------- | :------------------------- | 31 | | `# observe: varName` | Register variable observer for `varName` Ink variable. The variable value is available in the `vars` section of Atrament state. | 32 | | `# persist: varName` | Save this variable value to persistent storage, and restore it before the game starts. | 33 | | `# autosave: false` | Disables autosaves. | 34 | | `# single_scene` | Store only the last scene in the Atrament state. | 35 | | `# continue_maximally: false` | Pause story after each line. In this mode the `scene` object contains the `canContinue` field, which is set to true if the story can be continued. | 36 | 37 | 38 | ### Knot tags 39 | | Tag | Description | 40 | | :-------- | :------------------------- | 41 | | `# CLEAR` | Clear the scenes list before saving the current scene to Atrament state. | 42 | | `# AUDIO: sound.mp3` | Play sound (once). | 43 | | `# AUDIOLOOP: music.mp3` | Play background music (looped). There can be only one background music track. | 44 | | `# AUDIOLOOP: false` | Stop playing all background music. | 45 | | `# PLAY_SOUND: sound.mp3` | Play sound (once). | 46 | | `# STOP_SOUND: sound.mp3` | Stop playing specific sound. | 47 | | `# STOP_SOUND` | Stop playing all sounds. | 48 | | `# PLAY_MUSIC: music.mp3` | Play background music (looped). There can be multiple background music tracks, played simultaneously. | 49 | | `# STOP_MUSIC: music.mp3` | Stop playing specific background music. | 50 | | `# STOP_MUSIC` | Stop playing all background music. | 51 | | `# CHECKPOINT` | Save the game to the default checkpoint. | 52 | | `# CHECKPOINT: checkpointName` | Save the game to checkpoint `checkpointName`. | 53 | | `# SAVEGAME: saveslot` | Save the game to `saveslot`. | 54 | | `# RESTART` | Start game from beginning. | 55 | | `# RESTART_FROM_CHECKPOINT` | Restart the game from the latest checkpoint. | 56 | | `# RESTART_FROM_CHECKPOINT: checkpointName` | Restart game from named checkpoint. | 57 | | `# IMAGE: picture.jpg` | Adds specified image to the `images` attribute of the scene and paragraph. It can be used to preload image files for the scene. | 58 | 59 | Note: For sound effects, please use either AUDIO/AUDIOLOOP or PLAY_SOUND/PLAY_MUSIC/STOP_SOUND/STOP_MUSIC tags. Combining them may lead to unexpected side effects. 60 | 61 | ### Choice tags 62 | | Tag | Description | 63 | | :-------- | :------------------------- | 64 | | `# UNCLICKABLE` | Alternative names: `#DISABLED`, `#INACTIVE`.
Sets `disabled: true` attribute to the choice. | 65 | 66 | ## API Reference 67 | 68 | #### atrament.version 69 | 70 | Atrament version string. Read-only. 71 | 72 | ### Base methods 73 | 74 | #### atrament.defineInterfaces() 75 | 76 | Defines interface modules for: 77 | - **loader**: ink file loader 78 | - **persistent**: persistent storage 79 | - **sound**: sound control (optional) 80 | - **state**: state management 81 | 82 | Interfaces should be defined **before** calling any other methods. 83 | 84 | ``` 85 | atrament.defineInterfaces({ 86 | loader: interfaceLoader, 87 | persistent: persistentInterface, 88 | sound: soundInterface, 89 | state: stateInterface 90 | }); 91 | ``` 92 | 93 | #### atrament.init(Story, configuration) 94 | 95 | Initialize the game engine. Takes two parameters: 96 | - **Story** is an inkjs constructor, imported directly from inkjs 97 | - **configuration** is a configuration object: 98 | - **applicationID** should be a unique string. It is used to distinguish persistent storage of your application. 99 | - **settings** is a default settings object. These settings are immediately applied. 100 | 101 | ``` 102 | import {Story} from 'inkjs'; 103 | const config = { 104 | applicationID: 'your-application-id', 105 | settings: { 106 | mute: true, 107 | volume: 10, 108 | fullscreen: true 109 | } 110 | } 111 | atrament.init(Story, config); 112 | ``` 113 | 114 | #### atrament.on(event, listener) 115 | 116 | Subscribe to specific Atrament events. The **listener** function is called with a single argument containing event parameters. 117 | 118 | You can subscribe to all Atrament events: 119 | ``` 120 | atrament.on('*', (event, args) => { ... }); 121 | ``` 122 | 123 | #### atrament.off(event, listener) 124 | 125 | Unsubscribe specified listener from the Atrament event. 126 | 127 | #### atrament.state 128 | 129 | Returns Atrament state interface. Can be used to operate state directly: 130 | 131 | ``` 132 | atrament.state.setSubkey('game', 'checkpoint', true); 133 | ``` 134 | 135 | #### atrament.store 136 | 137 | Return raw store object. It can be used in hooks, for example: 138 | 139 | ``` 140 | const gamestate = useStore(atrament.store); 141 | ``` 142 | 143 | #### atrament.interfaces 144 | 145 | Returns raw interface objects. It can be used to operate with them directly. 146 | 147 | ``` 148 | const { state, persistent } = atrament.interfaces; 149 | ``` 150 | 151 | ### Game methods 152 | 153 | #### async atrament.game.init(path, file, gameID) 154 | 155 | Initialize game object. It is required to perform operations with saves. 156 | Parameters: 157 | - path: path to Ink file 158 | - file: Ink file name 159 | - gameID: optional. If provided, Atrament will use the given ID for save management. Otherwise, it will be generated based on path and filename. 160 | 161 | Event: `'game/init', { pathToInkFile: path, inkFile: file }` 162 | 163 | #### async atrament.game.initInkStory() 164 | 165 | Load Ink file and initialize Ink Story object. Then it updates game metadata and initializes variable observers. 166 | 167 | Event: `'game/initInkStory'` 168 | 169 | #### atrament.game.getSaveSlotKey({ name, type }) 170 | 171 | Returns save slot identifier for given save name and type. 172 | Possible save types: `atrament.game.SAVE_GAME`, `atrament.game.SAVE_CHECKPOINT`, `atrament.game.SAVE_AUTOSAVE`. For autosaves, the `name` parameter should be omitted. 173 | The returned value can be used as a `saveslot` parameter. 174 | 175 | #### async atrament.game.start(saveslot) 176 | 177 | If the game is started for the first time, or the initialized game is not the same as the current one - call `initInkStory` first. 178 | Clears game state, and gets initial data for variable observers. 179 | If `saveslot` is defined, load state from specified save. 180 | 181 | Event: `'game/start', { saveSlot: saveslot }` 182 | 183 | #### async atrament.game.resume() 184 | 185 | Resume saved game: 186 | - if autosave exists, resume from autosave 187 | - if checkpoints exist, resume from the newest checkpoint 188 | - otherwise, start a new game 189 | 190 | Event: `'game/resume', { saveSlot: saveslot }` 191 | 192 | #### async atrament.game.canResume() 193 | 194 | Returns save slot identifier if game can be resumed. 195 | 196 | Event: `'game/canResume', { saveSlot: saveslot }` 197 | 198 | #### async atrament.game.restart(saveslot) 199 | 200 | Restart the game from the specified save slot (if `saveslot` is not defined, start a new game). 201 | 202 | Event: `'game/restart', { saveSlot: saveslot }` 203 | 204 | #### async atrament.game.restartAndContinue(saveslot) 205 | 206 | Run `atrament.game.restart`, then run `atrament.game.continueStory()` to regenerate game content. 207 | 208 | #### async atrament.game.load(saveslot) 209 | 210 | Load game state from specified save slot. 211 | 212 | Event: `'game/load', saveslot` 213 | 214 | #### async atrament.game.saveGame(name) 215 | 216 | Save game state to save slot. 217 | 218 | Event: `'game/save', { type: 'game', name }` 219 | 220 | #### async atrament.game.saveCheckpoint(name) 221 | 222 | Save the game state to the checkpoint. 223 | 224 | Event: `'game/save', { type: 'checkpoint', name }` 225 | 226 | #### async atrament.game.saveAutosave() 227 | 228 | Save the game state to autosave slot. 229 | 230 | Event: `'game/save', { type: 'autosave' }` 231 | 232 | #### async atrament.game.listSaves() 233 | 234 | Returns array of all existing saves for active game. 235 | 236 | Event: `'game/listSaves', savesListArray` 237 | 238 | #### async atrament.game.removeSave(saveslot) 239 | 240 | Removes specified game save slot. 241 | 242 | Event: `'game/removeSave', saveslot` 243 | 244 | #### async atrament.game.existSave(saveslot) 245 | 246 | Returns `true` if specified save slot exists. 247 | 248 | #### atrament.game.continueStory() 249 | 250 | - gets Ink scene content 251 | - run scene processors 252 | - process tags 253 | - updates Atrament state with a scene content 254 | 255 | Event: `'game/continueStory'` 256 | 257 | Event for tag handling: `'game/handleTag', { [tagName]: tagValue }` 258 | 259 | #### atrament.game.makeChoice(id) 260 | 261 | Make a choice in Ink. Wrapper for `atrament.ink.makeChoice`. 262 | 263 | #### atrament.game.defineSceneProcessor(processorFunction) 264 | 265 | Register `processorFunction` for scene post-processing. It takes the `scene` object as an argument by reference: 266 | 267 | ``` 268 | function processCheckpoint(scene) { 269 | if (scene.tags.CHECKPOINT) { 270 | scene.is_checkpoint = true; 271 | } 272 | } 273 | atrament.game.defineSceneProcessor(processCheckpoint); 274 | 275 | ``` 276 | 277 | #### atrament.game.getAssetPath(file) 278 | 279 | Returns the full path to asset file (image, sound, music). 280 | 281 | #### atrament.game.clear() 282 | 283 | Method to call at the game end. It stops music, and clears `scenes` and `vars` in the Atrament state. 284 | 285 | Event: `'game/clear'` 286 | 287 | #### atrament.game.reset() 288 | 289 | Method to call at the game end. It calls `atrament.game.clear()`, then clears `metadata` and `game` in Atrament state. 290 | 291 | Event: `'game/reset'` 292 | 293 | #### atrament.game.getSession() 294 | 295 | Returns current game session. 296 | 297 | #### atrament.game.setSession(sessionID) 298 | 299 | Sets current game session. If set to empty value, reset session ID to default. 300 | 301 | Event: `'game/setSession', sessionID` 302 | 303 | #### async atrament.game.getSessions() 304 | 305 | Returns list of existing sessions in a `{ sessionName: numberOfSaves, ... }` format. 306 | 307 | Event: `'game/getSessions', sessionList` 308 | 309 | #### async atrament.game.deleteSession(sessionID) 310 | 311 | Delete all saves for a given session. 312 | 313 | Event: `'game/deleteSession', sessionID` 314 | 315 | 316 | ### Ink methods 317 | 318 | #### atrament.ink.initStory() 319 | 320 | Initializes Ink story with loaded content. 321 | 322 | Event: `'ink/initStory'` 323 | #### atrament.ink.story() 324 | 325 | Returns current Story instance. 326 | 327 | #### atrament.ink.loadState(state) 328 | 329 | Load Ink state from JSON. 330 | 331 | #### atrament.ink.getState() 332 | 333 | Returns current Ink state as JSON object. 334 | 335 | #### atrament.ink.makeChoice(id) 336 | 337 | Wrapper for `Story.ChooseChoiceIndex`. 338 | 339 | Event: `'ink/makeChoice', { id: choiceId }` 340 | 341 | #### atrament.ink.getVisitCount(ref) 342 | 343 | Wrapper for `Story.VisitCountAtPathString`. 344 | 345 | Event: `'ink/getVisitCount', { ref: ref, visitCount: value }` 346 | 347 | #### atrament.ink.evaluateFunction(functionName, argsArray) 348 | 349 | Evaluates Ink function, then returns the result of the evaluation. Wrapper for `Story.EvaluateFunction`. 350 | 351 | Event: `'ink/evaluateFunction', { function: functionName, args: argsArray, result: functionReturnValue }` 352 | 353 | #### atrament.ink.getGlobalTags() 354 | 355 | Returns parsed Ink global tags. 356 | 357 | Event: `'ink/getGlobalTags', { globalTags: globalTagsObject }` 358 | 359 | #### atrament.ink.getVariable(variableName) 360 | 361 | Returns value of specified Ink variable. 362 | 363 | Event: `'ink/getVariable', { name: variableName }` 364 | 365 | #### atrament.ink.getVariables() 366 | 367 | Returns all variables and their values as a key-value object. 368 | 369 | Event: `'ink/getVariables', inkVariablesObject` 370 | 371 | #### atrament.ink.setVariable(variableName, value) 372 | 373 | Sets value of specified Ink variable. 374 | 375 | Event: `'ink/setVariable', { name: variableName, value: value }` 376 | 377 | #### atrament.ink.observeVariable(variableName, observerFunction) 378 | 379 | Registers observer for a specified variable. Wrapper for `Story.ObserveVariable`. 380 | 381 | #### atrament.ink.goTo(ref) 382 | 383 | Go to the specified Ink knot or stitch. Wrapper for `Story.ChoosePathString`. 384 | 385 | Event: `'ink/goTo', { knot: ref }` 386 | 387 | #### atrament.ink.onError(errorCallback) 388 | 389 | When an Ink error occurs, it emits `ink/onError` event and calls the `errorCallback` function with the error event object as an argument. 390 | 391 | Event: `'ink/onError', errorEvent` 392 | 393 | #### atrament.ink.getScene() 394 | 395 | Returns **Scene** object. 396 | 397 | Event: `'ink/getScene', { scene: sceneObject }` 398 | 399 | ### Settings methods 400 | 401 | Application settings for your application. Loading, saving, and setting values changes the `settings` section of the Atrament state. 402 | 403 | However, if you need to perform additional actions when the setting is changed, you can define a handler for it - see below. By default, Atrament handles `mute` and `volume` settings this way, muting and setting sound volume respectively. 404 | 405 | #### async atrament.settings.load() 406 | 407 | Load settings from persistent storage to Atrament state. 408 | 409 | Event: `'settings/load'` 410 | 411 | #### async atrament.settings.save() 412 | 413 | Save settings to persistent storage. 414 | 415 | Event: `'settings/save'` 416 | 417 | #### atrament.settings.get(parameter) 418 | 419 | Returns value of the setting. 420 | 421 | Event: `'settings/get', { name: parameter }` 422 | 423 | #### atrament.settings.set(parameter, value) 424 | 425 | Sets value of setting. 426 | 427 | Event: `'settings/set', { name: parameter, value: value }` 428 | 429 | #### atrament.settings.toggle(parameter) 430 | 431 | Toggles setting (sets `true` to `false` and vice versa). 432 | 433 | #### atrament.settings.defineHandler(parameter, handlerFunction) 434 | 435 | Defines a settings handler. 436 | 437 | For example, you have to run some JavaScript code to toggle fullscreen mode in your app. 438 | 439 | ``` 440 | const fullscreenHandler = (oldValue, newValue) => { 441 | // do some actions 442 | } 443 | 444 | atrament.settings.defineHandler('fullscreen', fullscreenHandler); 445 | 446 | // later... 447 | 448 | atrament.toggle('fullscreen'); 449 | // or 450 | atrament.set('fullscreen', true); 451 | 452 | // both these methods will change the setting and run the corresponding handler 453 | ``` 454 | 455 | 456 | ## Scene object 457 | 458 | ``` 459 | { 460 | content: [], 461 | text: [], 462 | tags: {}, 463 | choices: [], 464 | images: [], 465 | sounds: [], 466 | music: [], 467 | uuid: Number 468 | } 469 | ``` 470 | 471 | | Key | Description | 472 | | :-------- | :------------------------- | 473 | | `content` | Array of Ink paragraphs: `{text: '', tags: {}, images: [], sounds: [], music: []}` | 474 | | `text` | Array of all story text from all paragraphs of this scene | 475 | | `tags` | Array of all tags from all paragraphs of this scene | 476 | | `choices` | Array of choice objects: `{ id: 0, choice: 'Choice Text', tags: {}}` | 477 | | `images` | Array of all images from all paragraphs of this scene | 478 | | `sound` | Array of all sounds from all paragraphs of this scene | 479 | | `music` | Array of all music tracks from all paragraphs of this scene | 480 | | `uuid` | Unique ID of the scene (`Date.now()`) | 481 | 482 | 483 | ## State structure 484 | 485 | ``` 486 | { 487 | settings: {}, 488 | game: {}, 489 | metadata: {}, 490 | scenes: [], 491 | vars: {} 492 | } 493 | ``` 494 | 495 | | Key | Description | 496 | | :-------- | :------------------------- | 497 | | `settings` | Single-level key-value store for application settings | 498 | | `game` | Single-level game-specific data. Atrament populates the following keys: *$pathToInkFile, $inkFile, $gameUUID* | 499 | | `metadata` | Data loaded from Ink file global tags | 500 | | `scenes` | Array of game scenes | 501 | | `vars` | Names and values of auto-observed variables | 502 | 503 | ## Save structure 504 | 505 | ``` 506 | { id, date, state, game, scenes } 507 | ``` 508 | | Key | Description | 509 | | :-------- | :------------------------- | 510 | | `id` | Save slot ID | 511 | | `date` | Save timestamp | 512 | | `state` | JSON structure of Ink state | 513 | | `game` | Content of `game` from Atrament state | 514 | | `scenes` | Content of `scenes` from Atrament state | 515 | 516 | Please note that `metadata` and `vars` from the Atrament state are not included in the save. However, they are automatically populated from the Ink state after loading from a save. 517 | 518 | 519 | ## Interfaces 520 | 521 | `atrament-core` uses dependency injection. It uses inkjs `Story` constructor 'as-is', and uses custom interfaces for other libraries. 522 | 523 | There are four interfaces in `atrament-core`. Their implementation is not included, so developers can use `atrament-core` with the libraries they like. 524 | 525 | ### loader 526 | Interface to file operations. The function `init` will be called first, taking the path to the game as a parameter. The function `getAssetPath` should return the full path of a given file. The async function `loadInk` should return the content of a given Ink file, located in the folder defined at the initialization time. 527 | 528 | ``` 529 | { 530 | async init(path) 531 | getAssetPath(filename) 532 | async loadInk(filename) 533 | } 534 | ``` 535 | 536 | ### persistent 537 | Interface to persistent storage library. 538 | 539 | ``` 540 | { 541 | init() 542 | async exists(key) 543 | async get() 544 | async set(key) 545 | async remove(key) 546 | async keys() 547 | } 548 | ``` 549 | 550 | ### state 551 | Interface to state management library. 552 | 553 | ``` 554 | { 555 | store() 556 | get() 557 | setKey(key, value) 558 | toggleKey(key) 559 | appendKey(key, value) 560 | setSubkey(key, subkey, value) 561 | toggleSubkey(key, subkey) 562 | appendSubkey(key, subkey, value) 563 | } 564 | ``` 565 | 566 | ### sound 567 | Interface to sound management library. 568 | ``` 569 | { 570 | init(defaultSettings) 571 | mute(flag) 572 | isMuted() 573 | setVolume(volume) 574 | getVolume() 575 | playSound(soundFile) 576 | stopSound(soundFile|undefined) 577 | playMusic(musicFile) 578 | stopMusic(musicFile|undefined) 579 | } 580 | ``` 581 | 582 | ## LICENSE 583 | 584 | Atrament is distributed under MIT license. 585 | 586 | Copyright (c) 2023 Serhii "techniX" Mozhaiskyi 587 | 588 | Made with the support of the [Interactive Fiction Technology Foundation](https://iftechfoundation.org/) 589 | 590 | 591 | -------------------------------------------------------------------------------- /__mocks__/persistent.js: -------------------------------------------------------------------------------- 1 | const gameState = {}; 2 | let key = ''; 3 | 4 | function dbkey(id) { 5 | return `${key}${id}`; 6 | } 7 | 8 | function reset() { 9 | key = ''; 10 | Object.keys(gameState).forEach((k) => delete gameState[k]); 11 | } 12 | 13 | function init(prefix) { 14 | key = `>>${prefix}<<`; 15 | } 16 | 17 | async function exists(id) { 18 | return !!gameState[dbkey(id)]; 19 | } 20 | 21 | async function get(id) { 22 | return JSON.parse(gameState[dbkey(id)]); 23 | } 24 | 25 | async function set(id, state) { 26 | gameState[dbkey(id)] = JSON.stringify(state); 27 | } 28 | 29 | async function remove(id) { 30 | delete gameState[dbkey(id)]; 31 | } 32 | 33 | async function keys() { 34 | return Object.keys(gameState).filter((k) => k.includes(key)).map((k) => k.replace(key, '')); 35 | } 36 | 37 | export default { 38 | gameState, 39 | reset, 40 | init, 41 | exists, 42 | get, 43 | set, 44 | remove, 45 | keys 46 | }; 47 | -------------------------------------------------------------------------------- /__mocks__/state.js: -------------------------------------------------------------------------------- 1 | const atramentState = { 2 | settings: {}, 3 | game: {}, 4 | metadata: {}, 5 | scenes: [], 6 | vars: {} 7 | }; 8 | 9 | function reset() { 10 | atramentState.settings = {}; 11 | atramentState.game = {}; 12 | atramentState.metadata = {}; 13 | atramentState.scenes = []; 14 | atramentState.vars = {}; 15 | } 16 | 17 | function store() { 18 | return atramentState; 19 | } 20 | 21 | function get() { 22 | return JSON.parse(JSON.stringify(atramentState)); 23 | } 24 | 25 | function setKey(name, value) { 26 | atramentState[name] = value; 27 | } 28 | 29 | function toggleKey(name) { 30 | atramentState[name] = !atramentState[name]; 31 | } 32 | 33 | function appendKey(name, value) { 34 | atramentState[name] = [...atramentState[name], value]; 35 | } 36 | 37 | function setSubkey(name, subname, value) { 38 | atramentState[name] = { ...atramentState[name], [subname]: value }; 39 | } 40 | 41 | function toggleSubkey(name, subname) { 42 | atramentState[name] = { ...atramentState[name], [subname]: !atramentState[name][subname] }; 43 | } 44 | 45 | function appendSubkey(name, subname, value) { 46 | atramentState[name] = { ...atramentState[name], [subname]: [...atramentState[name][subname], value] }; 47 | } 48 | 49 | export default { 50 | reset, 51 | store, 52 | get, 53 | setKey, 54 | toggleKey, 55 | appendKey, 56 | setSubkey, 57 | toggleSubkey, 58 | appendSubkey 59 | }; 60 | -------------------------------------------------------------------------------- /__tests__/components/game/control.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import mockPersistent from '../../../__mocks__/persistent'; 3 | import mockState from '../../../__mocks__/state'; 4 | 5 | import { emit } from '../../../src/utils/emitter'; 6 | import hashCode from '../../../src/utils/hashcode'; 7 | 8 | import ink from '../../../src/components/ink'; 9 | import { persistentPrefix, load, existSave, save, SAVE_GAME, getSaveSlotKey } from '../../../src/components/saves'; 10 | import { playMusic, stopMusic } from '../../../src/components/sound'; 11 | 12 | import { init, loadInkFile, initInkStory, start, clear, reset } from '../../../src/components/game/control'; 13 | 14 | let mockGlobalTags; 15 | let mockObserver; 16 | let mockInkContent; 17 | const mockInkState = { inkState: true }; 18 | const mockLoader = jest.fn(() => Promise.resolve(mockInkContent)); 19 | const mockInitLoader = jest.fn(() => Promise.resolve()); 20 | const mockGetAssetPath = jest.fn((file) => `${mockState.get().game.$path}/${file}`); 21 | 22 | jest.mock('../../../src/components/sound', () => ({ 23 | playMusic: jest.fn(), 24 | stopMusic: jest.fn() 25 | })); 26 | 27 | jest.mock('../../../src/utils/emitter', () => ({ 28 | emit: jest.fn() 29 | })); 30 | 31 | jest.mock('../../../src/components/ink', () => ({ 32 | initStory: jest.fn(), 33 | getVariable: jest.fn((v) => `${v}-value`), 34 | setVariable: jest.fn(), 35 | observeVariable: jest.fn((v, handler) => { mockObserver = handler; }), 36 | getGlobalTags: jest.fn(() => mockGlobalTags), 37 | resetStory: jest.fn(), 38 | getState: jest.fn(() => mockInkState), 39 | loadState: jest.fn(() => mockInkState) 40 | })); 41 | 42 | jest.mock('../../../src/utils/interfaces', () => ({ 43 | interfaces: jest.fn(() => ({ 44 | state: mockState, 45 | persistent: mockPersistent, 46 | loader: { 47 | init: mockInitLoader, 48 | loadInk: mockLoader, 49 | getAssetPath: mockGetAssetPath 50 | } 51 | })) 52 | })); 53 | 54 | jest.mock('../../../src/components/saves', () => { 55 | const saves = jest.requireActual('../../../src/components/saves'); 56 | return { 57 | ...saves, 58 | load: jest.spyOn(saves, 'load'), 59 | save: jest.spyOn(saves, 'save'), 60 | existSave: jest.spyOn(saves, 'existSave') 61 | }; 62 | }); 63 | 64 | 65 | beforeEach(() => { 66 | mockState.reset(); 67 | mockPersistent.reset(); 68 | jest.clearAllMocks(); 69 | mockGlobalTags = []; 70 | mockObserver = () => {}; 71 | }); 72 | 73 | describe('components/game', () => { 74 | // ============================================================ 75 | // init 76 | // ============================================================ 77 | describe('init', () => { 78 | test('default', async () => { 79 | const pathToInkFile = '/some/directory'; 80 | const inkFile = 'game.ink.json'; 81 | expect(emit).not.toHaveBeenCalled(); 82 | await init(pathToInkFile, inkFile); 83 | expect(mockState.get().game).toEqual({ 84 | $path: pathToInkFile, 85 | $file: inkFile, 86 | $gameUUID: hashCode(`${pathToInkFile}|${inkFile}`) 87 | }); 88 | expect(mockInitLoader).toHaveBeenCalledWith(pathToInkFile); 89 | expect(emit).toHaveBeenCalledWith('game/init', { pathToInkFile, inkFile }); 90 | }); 91 | 92 | test('init - custom game UUID', async () => { 93 | const pathToInkFile = '/some/directory'; 94 | const inkFile = 'game.ink.json'; 95 | expect(emit).not.toHaveBeenCalled(); 96 | await init(pathToInkFile, inkFile, 'customUUID'); 97 | expect(mockState.get().game).toEqual({ 98 | $path: pathToInkFile, 99 | $file: inkFile, 100 | $gameUUID: 'customUUID' 101 | }); 102 | }); 103 | }); 104 | 105 | // ============================================================ 106 | // loadInkFile 107 | // ============================================================ 108 | test('loadInkFile', async () => { 109 | mockInkContent = '{"data":"ink content"}'; 110 | const pathToInkFile = '/some/directory'; 111 | const inkFile = 'game.ink.json'; 112 | await init(pathToInkFile, inkFile); 113 | expect(mockLoader).not.toHaveBeenCalled(); 114 | emit.mockClear(); 115 | const inkContent = await loadInkFile(); 116 | expect(inkContent).toEqual({ data: 'ink content' }); 117 | expect(mockLoader).toHaveBeenCalledWith(inkFile); 118 | expect(emit).toHaveBeenCalledWith('game/loadInkFile', inkFile); 119 | }); 120 | 121 | // ============================================================ 122 | // clear 123 | // ============================================================ 124 | test('clear', async () => { 125 | // setup 126 | expect(emit).not.toHaveBeenCalled(); 127 | mockState.setKey('scenes', [{ text: 'aaa' }, { text: 'aaa' }]); 128 | mockState.setKey('vars', { aaa: 'bbb' }); 129 | mockState.setKey('metadata', { ccc: 'ddd' }); 130 | mockState.setKey('game', { ddd: 'eee' }); 131 | expect(stopMusic).not.toHaveBeenCalled(); 132 | expect(ink.resetStory).not.toHaveBeenCalled(); 133 | // run 134 | clear(); 135 | // check 136 | expect(stopMusic).toHaveBeenCalledTimes(1); 137 | expect(mockState.get().scenes).toEqual([]); 138 | expect(mockState.get().vars).toEqual({}); 139 | expect(mockState.get().metadata).toEqual({ ccc: 'ddd' }); 140 | expect(mockState.get().game).toEqual({ ddd: 'eee' }); 141 | expect(ink.resetStory).toHaveBeenCalledTimes(1); 142 | expect(emit).toHaveBeenCalledWith('game/clear'); 143 | }); 144 | 145 | // ============================================================ 146 | // reset 147 | // ============================================================ 148 | test('reset', async () => { 149 | // setup 150 | expect(emit).not.toHaveBeenCalled(); 151 | mockState.setKey('scenes', [{ text: 'aaa' }, { text: 'aaa' }]); 152 | mockState.setKey('vars', { aaa: 'bbb' }); 153 | mockState.setKey('metadata', { ccc: 'ddd' }); 154 | mockState.setKey('game', { ddd: 'eee' }); 155 | expect(stopMusic).not.toHaveBeenCalled(); 156 | // run 157 | reset(); 158 | // check 159 | expect(stopMusic).toHaveBeenCalledTimes(1); 160 | expect(mockState.get().scenes).toEqual([]); 161 | expect(mockState.get().vars).toEqual({}); 162 | expect(mockState.get().metadata).toEqual({}); 163 | expect(mockState.get().game).toEqual({}); 164 | expect(emit).toHaveBeenCalledWith('game/reset'); 165 | }); 166 | 167 | // ============================================================ 168 | // start 169 | // ============================================================ 170 | describe('start', () => { 171 | beforeEach(async () => { 172 | // reset 173 | await init(null, null, 'NONEXISTENT'); 174 | await initInkStory(); 175 | jest.clearAllMocks(); 176 | }); 177 | 178 | test('default', async () => { 179 | // set 180 | mockInkContent = { inkstory: 'inkContent' }; 181 | mockGlobalTags = { globaltag1: true, globaltag2: true }; 182 | mockState.setKey('scenes', [{ text: 'aaa' }, { text: 'aaa' }]); 183 | mockState.setKey('vars', { aaa: 'bbb' }); 184 | mockState.setKey('metadata', { ccc: 'ddd' }); 185 | const pathToInkFile = '/some/directory'; 186 | const inkFile = 'game.ink.json'; 187 | await init(pathToInkFile, inkFile); 188 | // run 189 | await start(); 190 | // check 191 | expect(ink.initStory).toHaveBeenCalledTimes(1); 192 | expect(ink.initStory).toHaveBeenCalledWith(mockInkContent); 193 | expect(stopMusic).toHaveBeenCalledTimes(1); 194 | expect(mockState.get().scenes).toEqual([]); 195 | expect(mockState.get().vars).toEqual({}); 196 | expect(mockState.get().metadata).toEqual(mockGlobalTags); 197 | expect(ink.observeVariable).not.toHaveBeenCalled(); 198 | expect(existSave).not.toHaveBeenCalled(); 199 | expect(load).not.toHaveBeenCalled(); 200 | expect(emit).toHaveBeenCalledWith('game/start', { saveSlot: undefined }); 201 | }); 202 | 203 | test('restore single observer', async () => { 204 | // set 205 | mockGlobalTags = { observe: 'var1' }; 206 | const pathToInkFile = '/some/directory'; 207 | const inkFile = 'game.ink.json'; 208 | await init(pathToInkFile, inkFile); 209 | expect(mockState.get().vars).toEqual({}); 210 | // run 211 | await initInkStory(); 212 | // expect observers to be registered, but values are not set yet 213 | expect(mockState.get().metadata).toEqual(mockGlobalTags); 214 | expect(ink.observeVariable).toHaveBeenCalledTimes(1); 215 | expect(ink.observeVariable).toHaveBeenCalledWith('var1', expect.any(Function)); 216 | expect(mockState.get().vars).toEqual({}); 217 | // run 218 | await start(); 219 | // check 220 | expect(mockState.get().vars).toEqual({ 221 | var1: 'var1-value' 222 | }); 223 | expect(load).not.toHaveBeenCalled(); 224 | // check observers 225 | emit.mockClear(); 226 | mockObserver('var1', 50); 227 | expect(mockState.get().vars).toEqual({ 228 | var1: 50 229 | }); 230 | expect(emit).toHaveBeenCalledWith('ink/variableObserver', { name: 'var1', value: 50 }); 231 | }); 232 | 233 | test('restore observers', async () => { 234 | // set 235 | mockGlobalTags = { observe: ['var1', 'var2'] }; 236 | const pathToInkFile = '/some/directory'; 237 | const inkFile = 'game.ink.json'; 238 | await init(pathToInkFile, inkFile); 239 | expect(mockState.get().vars).toEqual({}); 240 | // run 241 | await initInkStory(); 242 | // expect observers to be registered, but values are not set yet 243 | expect(mockState.get().metadata).toEqual(mockGlobalTags); 244 | expect(ink.observeVariable).toHaveBeenCalledTimes(2); 245 | expect(ink.observeVariable).toHaveBeenCalledWith('var1', expect.any(Function)); 246 | expect(ink.observeVariable).toHaveBeenCalledWith('var2', expect.any(Function)); 247 | expect(mockState.get().vars).toEqual({}); 248 | // run 249 | await start(); 250 | // check 251 | expect(mockState.get().vars).toEqual({ 252 | var1: 'var1-value', 253 | var2: 'var2-value' 254 | }); 255 | expect(load).not.toHaveBeenCalled(); 256 | // check observers 257 | emit.mockClear(); 258 | mockObserver('var1', 50); 259 | expect(mockState.get().vars).toEqual({ 260 | var1: 50, 261 | var2: 'var2-value' 262 | }); 263 | expect(emit).toHaveBeenCalledWith('ink/variableObserver', { name: 'var1', value: 50 }); 264 | }); 265 | 266 | test('handle persistent vars', async () => { 267 | // set 268 | mockGlobalTags = { persist: ['var1', 'var2'] }; 269 | const pathToInkFile = '/some/directory'; 270 | const inkFile = 'game.ink.json'; 271 | await init(pathToInkFile, inkFile); 272 | const persistentStore = persistentPrefix('persist'); 273 | expect(mockState.get().vars).toEqual({}); 274 | // run 275 | await start(); 276 | // expect observers to be registered, but no data are saved 277 | expect(mockState.get().metadata).toEqual(mockGlobalTags); 278 | expect(ink.observeVariable).toHaveBeenCalledTimes(2); 279 | expect(ink.observeVariable).toHaveBeenCalledWith('var1', expect.any(Function)); 280 | expect(ink.observeVariable).toHaveBeenCalledWith('var2', expect.any(Function)); 281 | expect(await mockPersistent.exists(persistentStore)).toEqual(false); 282 | await mockObserver('var1', 50); 283 | const state1 = await mockPersistent.get(persistentStore); 284 | expect(state1).toEqual({ var1: 50 }); 285 | expect(ink.setVariable).not.toHaveBeenCalled(); 286 | // reinit the game 287 | // run 288 | await start(); 289 | expect(ink.setVariable).toHaveBeenCalledTimes(1); 290 | expect(ink.setVariable).toHaveBeenCalledWith('var1', 50); 291 | }); 292 | 293 | test('load from nonexistent save', async () => { 294 | // set 295 | const pathToInkFile = '/some/directory'; 296 | const inkFile = 'game.ink.json'; 297 | await init(pathToInkFile, inkFile); 298 | // run 299 | await start('somesave'); 300 | // check 301 | expect(existSave).toHaveBeenCalledTimes(1); 302 | expect(existSave).toHaveBeenCalledWith('somesave'); 303 | expect(load).not.toHaveBeenCalled(); 304 | }); 305 | 306 | test('load from existing save', async () => { 307 | // set 308 | const pathToInkFile = '/some/directory'; 309 | const inkFile = 'game.ink.json'; 310 | const saveID = getSaveSlotKey({ type: SAVE_GAME, name: 'existingsave' }); 311 | save({ type: SAVE_GAME, name: 'existingsave' }); 312 | await init(pathToInkFile, inkFile); 313 | // run 314 | await start(saveID); 315 | // check 316 | expect(ink.initStory).toHaveBeenCalledTimes(1); 317 | expect(existSave).toHaveBeenCalledTimes(1); 318 | expect(existSave).toHaveBeenCalledWith(saveID); 319 | expect(load).toHaveBeenCalledTimes(1); 320 | expect(load).toHaveBeenCalledWith(saveID); 321 | }); 322 | 323 | test('load from existing save - story is initialized', async () => { 324 | // set 325 | const pathToInkFile = '/some/directory'; 326 | const inkFile = 'game.ink.json'; 327 | const saveID = getSaveSlotKey({ type: SAVE_GAME, name: 'existingsave' }); 328 | save({ type: SAVE_GAME, name: 'existingsave' }); 329 | await init(pathToInkFile, inkFile); 330 | // run 331 | await start(saveID); 332 | // check 333 | expect(ink.initStory).toHaveBeenCalledTimes(1); 334 | expect(existSave).toHaveBeenCalledTimes(1); 335 | expect(existSave).toHaveBeenCalledWith(saveID); 336 | expect(load).toHaveBeenCalledTimes(1); 337 | expect(load).toHaveBeenCalledWith(saveID); 338 | }); 339 | 340 | test('load ink file while starting', async () => { 341 | // set 342 | const pathToInkFile = '/some/directory'; 343 | const inkFile = 'game.ink.json'; 344 | await init(pathToInkFile, inkFile); 345 | mockInkContent = null; // reset ink content 346 | await loadInkFile(); 347 | mockLoader.mockClear(); 348 | // run 349 | await start(); 350 | // check 351 | expect(mockLoader).toHaveBeenCalledWith(inkFile); 352 | expect(emit).toHaveBeenCalledWith('game/loadInkFile', inkFile); 353 | }); 354 | 355 | test('load game state - restore music', async () => { 356 | // set 357 | const saveID = 'test_save_id'; 358 | const pathToInkFile = '/some/directory'; 359 | const inkFile = 'game.ink.json'; 360 | mockPersistent.set(saveID, { game: { $currentMusic: 'music.mp3' } }); 361 | await init(pathToInkFile, inkFile); 362 | // run 363 | expect(playMusic).not.toHaveBeenCalled(); 364 | await start(saveID); 365 | // check 366 | expect(playMusic).toHaveBeenCalledWith('music.mp3'); 367 | expect(emit).toHaveBeenCalledWith('game/start', { saveSlot: saveID }); 368 | }); 369 | 370 | test('load game state - restore music - false', async () => { 371 | // set 372 | const saveID = 'test_save_id'; 373 | const pathToInkFile = '/some/directory'; 374 | const inkFile = 'game.ink.json'; 375 | mockPersistent.set(saveID, { game: { $currentMusic: false } }); 376 | await init(pathToInkFile, inkFile); 377 | // run 378 | expect(playMusic).not.toHaveBeenCalled(); 379 | await start(saveID); 380 | // check 381 | expect(playMusic).not.toHaveBeenCalled(); 382 | expect(emit).toHaveBeenCalledWith('game/start', { saveSlot: saveID }); 383 | }); 384 | }); 385 | 386 | describe('start of the same story', () => { 387 | beforeEach(async () => { 388 | const pathToInkFile = '/some/directory'; 389 | const inkFile = 'game.ink.json'; 390 | await init(pathToInkFile, inkFile); 391 | }); 392 | 393 | test('start from beginning - same ink story', async () => { 394 | // set 395 | const pathToInkFile = '/some/directory'; 396 | const inkFile = 'game.ink.json'; 397 | await init(pathToInkFile, inkFile); 398 | // run 399 | await start(); 400 | // check 401 | expect(ink.initStory).not.toHaveBeenCalled(); // this story is already initialized 402 | expect(ink.observeVariable).not.toHaveBeenCalled(); 403 | expect(load).not.toHaveBeenCalled(); 404 | }); 405 | 406 | test('other story + ink content as string', async () => { 407 | // set 408 | mockInkContent = '{"inkstory":"inkContent"}'; 409 | const pathToInkFile = '/some/directory2'; 410 | const inkFile = 'game2.ink.json'; 411 | await init(pathToInkFile, inkFile); 412 | await loadInkFile(); 413 | // run 414 | await start(); 415 | // check 416 | expect(ink.initStory).toHaveBeenCalledTimes(1); 417 | expect(ink.initStory).toHaveBeenCalledWith({ inkstory: 'inkContent' }); 418 | expect(ink.observeVariable).not.toHaveBeenCalled(); 419 | expect(load).not.toHaveBeenCalled(); 420 | }); 421 | }); 422 | }); 423 | -------------------------------------------------------------------------------- /__tests__/components/game/index.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import mockPersistent from '../../../__mocks__/persistent'; 3 | import mockState from '../../../__mocks__/state'; 4 | 5 | import { emit } from '../../../src/utils/emitter'; 6 | 7 | import ink from '../../../src/components/ink'; 8 | import { playMusic, stopMusic, playSound, playSingleMusic, stopSound } from '../../../src/components/sound'; 9 | import { 10 | load, 11 | save, 12 | existSave, 13 | removeSave, 14 | listSaves, 15 | SAVE_AUTOSAVE, 16 | SAVE_CHECKPOINT, 17 | SAVE_GAME, 18 | getSaveSlotKey 19 | } from '../../../src/components/saves'; 20 | 21 | import game from '../../../src/components/game'; 22 | 23 | let mockGlobalTags; 24 | let mockScene; 25 | let mockInkContent; 26 | const mockInitLoader = jest.fn(() => Promise.resolve()); 27 | const mockLoader = jest.fn(() => Promise.resolve(mockInkContent)); 28 | const mockInkState = { inkState: true }; 29 | const mockGetAssetPath = jest.fn((file) => `${mockState.get().game.$path}/${file}`); 30 | 31 | const pause = (ms) => new Promise((res) => { setTimeout(res, ms); }); 32 | 33 | jest.mock('../../../src/utils/emitter', () => ({ 34 | emit: jest.fn() 35 | })); 36 | 37 | jest.mock('../../../src/components/ink', () => ({ 38 | initStory: jest.fn(), 39 | getState: jest.fn(() => mockInkState), 40 | loadState: jest.fn(() => mockInkState), 41 | getGlobalTags: jest.fn(() => mockGlobalTags), 42 | getVariable: jest.fn((v) => `${v}-value`), 43 | setVariable: jest.fn(), 44 | getScene: jest.fn(() => JSON.parse(JSON.stringify(mockScene))), 45 | makeChoice: jest.fn(), 46 | resetStory: jest.fn() 47 | })); 48 | 49 | jest.mock('../../../src/components/sound', () => ({ 50 | playMusic: jest.fn(), 51 | stopMusic: jest.fn(), 52 | playSound: jest.fn(), 53 | playSingleMusic: jest.fn(), 54 | stopSound: jest.fn() 55 | })); 56 | 57 | jest.mock('../../../src/components/saves', () => { 58 | const saves = jest.requireActual('../../../src/components/saves'); 59 | return { 60 | ...saves, 61 | load: jest.spyOn(saves, 'load'), 62 | save: jest.spyOn(saves, 'save'), 63 | existSave: jest.spyOn(saves, 'existSave'), 64 | removeSave: jest.spyOn(saves, 'removeSave'), 65 | listSaves: jest.spyOn(saves, 'listSaves') 66 | }; 67 | }); 68 | 69 | 70 | jest.mock('../../../src/utils/interfaces', () => ({ 71 | interfaces: jest.fn(() => ({ 72 | state: mockState, 73 | persistent: mockPersistent, 74 | loader: { 75 | init: mockInitLoader, 76 | loadInk: mockLoader, 77 | getAssetPath: mockGetAssetPath 78 | } 79 | })) 80 | })); 81 | 82 | 83 | beforeEach(() => { 84 | mockState.reset(); 85 | mockPersistent.reset(); 86 | jest.clearAllMocks(); 87 | mockScene = {}; 88 | mockGlobalTags = []; 89 | }); 90 | 91 | describe('components/game', () => { 92 | // ============================================================ 93 | // canResume 94 | // ============================================================ 95 | describe('canResume', () => { 96 | test('default', async () => { 97 | const expectedSaveslot = null; // no saves, can't resume 98 | const canResume = await game.canResume(); 99 | expect(canResume).toBe(expectedSaveslot); 100 | expect(emit).toHaveBeenCalledWith('game/canResume', expectedSaveslot); 101 | }); 102 | 103 | test('autosave', async () => { 104 | // set 105 | const expectedSaveslot = getSaveSlotKey({ type: SAVE_AUTOSAVE }); 106 | await game.saveAutosave(); 107 | // run 108 | const canResume = await game.canResume(); 109 | // check 110 | expect(canResume).toBe(expectedSaveslot); 111 | expect(emit).toHaveBeenCalledWith('game/canResume', expectedSaveslot); 112 | }); 113 | 114 | test('both autosave and checkpoints', async () => { 115 | // set 116 | await game.saveAutosave(); 117 | await game.saveCheckpoint('point1'); 118 | await pause(1); 119 | await game.saveCheckpoint('point2'); 120 | await pause(1); 121 | await game.saveCheckpoint('point3'); 122 | const expectedSaveslot = getSaveSlotKey({ type: SAVE_AUTOSAVE }); // autosave has higher precedence 123 | // run 124 | const canResume = await game.canResume(); 125 | // check 126 | expect(canResume).toBe(expectedSaveslot); 127 | expect(emit).toHaveBeenCalledWith('game/canResume', expectedSaveslot); 128 | }); 129 | 130 | test('checkpoints', async () => { 131 | // set 132 | await game.saveCheckpoint('point1'); 133 | await pause(1); 134 | await game.saveCheckpoint('point2'); 135 | await pause(1); 136 | await game.saveCheckpoint('point3'); 137 | const expectedSaveslot = getSaveSlotKey({ type: SAVE_CHECKPOINT, name: 'point3' }); // latest checkpoint 138 | // run 139 | const canResume = await game.canResume(); 140 | // check 141 | expect(canResume).toBe(expectedSaveslot); 142 | expect(emit).toHaveBeenCalledWith('game/canResume', expectedSaveslot); 143 | }); 144 | }); 145 | 146 | // ============================================================ 147 | // resume 148 | // ============================================================ 149 | describe('resume', () => { 150 | beforeEach(async () => { 151 | const pathToInkFile = '/some/directory'; 152 | const inkFile = 'game.ink.json'; 153 | await game.init(pathToInkFile, inkFile); 154 | }); 155 | 156 | test('cannot resume - start again', async () => { 157 | expect(ink.resetStory).not.toHaveBeenCalled(); 158 | await game.resume(); 159 | expect(load).not.toHaveBeenCalled(); 160 | expect(ink.resetStory).toHaveBeenCalledTimes(1); 161 | expect(emit).toHaveBeenCalledWith('game/resume', { saveSlot: null }); 162 | expect(emit).toHaveBeenCalledWith('game/start', { saveSlot: null }); 163 | }); 164 | 165 | test('can resume - start from save', async () => { 166 | expect(ink.resetStory).not.toHaveBeenCalled(); 167 | const saveID = getSaveSlotKey({ type: SAVE_AUTOSAVE }); 168 | mockPersistent.set(saveID, { type: SAVE_AUTOSAVE, game: {} }); 169 | await game.resume(); 170 | expect(load).toHaveBeenCalledWith(saveID); 171 | expect(ink.resetStory).toHaveBeenCalledTimes(1); 172 | expect(emit).toHaveBeenCalledWith('game/resume', { saveSlot: saveID }); 173 | expect(emit).toHaveBeenCalledWith('game/start', { saveSlot: saveID }); 174 | }); 175 | }); 176 | 177 | // ============================================================ 178 | // restart 179 | // ============================================================ 180 | describe('restart', () => { 181 | beforeEach(async () => { 182 | const pathToInkFile = '/some/directory'; 183 | const inkFile = 'game.ink.json'; 184 | await game.init(pathToInkFile, inkFile); 185 | mockScene = { content: [{ text: 'aaa' }], text: ['aaaa'], tags: {}, choices: [] }; 186 | }); 187 | 188 | test('save slot is not set', async () => { 189 | expect(ink.resetStory).not.toHaveBeenCalled(); 190 | expect(ink.getScene).not.toHaveBeenCalled(); 191 | // run 192 | await game.saveAutosave(); 193 | await game.restart(); 194 | // check 195 | expect(load).not.toHaveBeenCalled(); 196 | expect(ink.resetStory).toHaveBeenCalledTimes(1); 197 | expect(ink.getScene).not.toHaveBeenCalled(); // continueStory is not called 198 | expect(emit).toHaveBeenCalledWith('game/restart', { saveSlot: undefined }); 199 | expect(emit).toHaveBeenCalledWith('game/start', { saveSlot: undefined }); 200 | }); 201 | 202 | test('save slot is set', async () => { 203 | expect(ink.resetStory).not.toHaveBeenCalled(); 204 | expect(ink.getScene).not.toHaveBeenCalled(); 205 | // run 206 | const saveID = getSaveSlotKey({ type: SAVE_AUTOSAVE }); 207 | await game.saveAutosave(); 208 | await game.restart(saveID); 209 | // check 210 | expect(load).toHaveBeenCalledWith(saveID); 211 | expect(ink.resetStory).toHaveBeenCalledTimes(1); 212 | expect(ink.getScene).not.toHaveBeenCalled(); // continueStory is not called 213 | expect(emit).toHaveBeenCalledWith('game/restart', { saveSlot: saveID }); 214 | expect(emit).toHaveBeenCalledWith('game/start', { saveSlot: saveID }); 215 | }); 216 | }); 217 | 218 | // ============================================================ 219 | // restartAndContinue 220 | // ============================================================ 221 | describe('restartAndContinue', () => { 222 | beforeEach(async () => { 223 | const pathToInkFile = '/some/directory'; 224 | const inkFile = 'game.ink.json'; 225 | await game.init(pathToInkFile, inkFile); 226 | mockScene = { content: [{ text: 'aaa' }], text: ['aaaa'], tags: {}, choices: [] }; 227 | }); 228 | 229 | test('save slot is not set', async () => { 230 | expect(ink.resetStory).not.toHaveBeenCalled(); 231 | expect(ink.getScene).not.toHaveBeenCalled(); 232 | // run 233 | await game.saveAutosave(); 234 | await game.restartAndContinue(); 235 | // check 236 | expect(load).not.toHaveBeenCalled(); 237 | expect(ink.resetStory).toHaveBeenCalledTimes(1); 238 | expect(ink.getScene).toHaveBeenCalledTimes(1); 239 | expect(emit).toHaveBeenCalledWith('game/restart', { saveSlot: undefined }); 240 | expect(emit).toHaveBeenCalledWith('game/start', { saveSlot: undefined }); 241 | }); 242 | 243 | test('save slot is set', async () => { 244 | expect(ink.resetStory).not.toHaveBeenCalled(); 245 | expect(ink.getScene).not.toHaveBeenCalled(); 246 | // run 247 | const saveID = getSaveSlotKey({ type: SAVE_AUTOSAVE }); 248 | await game.saveAutosave(); 249 | await game.restartAndContinue(saveID); 250 | // check 251 | expect(load).toHaveBeenCalledWith(saveID); 252 | expect(ink.resetStory).toHaveBeenCalledTimes(1); 253 | expect(ink.getScene).toHaveBeenCalledTimes(1); 254 | expect(emit).toHaveBeenCalledWith('game/restart', { saveSlot: saveID }); 255 | expect(emit).toHaveBeenCalledWith('game/start', { saveSlot: saveID }); 256 | }); 257 | }); 258 | 259 | // ============================================================ 260 | // continueStory 261 | // ============================================================ 262 | describe('continueStory', () => { 263 | const pathToInkFile = '/some/directory'; 264 | const inkFile = 'game.ink.json'; 265 | const processedMockScene = { 266 | content: [{ text: 'aaa', images: [], sounds: [], music: [] }], 267 | text: ['aaaa'], 268 | tags: {}, 269 | choices: [], 270 | images: [], 271 | sounds: [], 272 | music: [] 273 | }; 274 | beforeEach(async () => { 275 | await game.init(pathToInkFile, inkFile); 276 | mockScene = { content: [{ text: 'aaa' }], text: ['aaaa'], tags: {}, choices: [] }; 277 | }); 278 | 279 | test('scene with empty content', () => { 280 | mockScene = { content: [] }; 281 | game.continueStory(); 282 | expect(mockState.get().scenes).toEqual([]); 283 | }); 284 | 285 | test('scene basics', () => { 286 | mockScene = { content: [{ text: 'aaa' }], text: ['aaaa'], tags: {}, choices: [] }; 287 | game.continueStory(); 288 | expect(emit).toHaveBeenCalledWith('game/continueStory'); 289 | expect(mockState.get().scenes).toEqual([processedMockScene]); 290 | game.continueStory(); 291 | expect(mockState.get().scenes).toEqual([processedMockScene, processedMockScene]); 292 | expect(save).toHaveBeenCalledWith({ type: SAVE_AUTOSAVE }); // autosave by default 293 | }); 294 | 295 | test('scene - single scene', () => { 296 | mockState.setKey('metadata', { single_scene: true }); 297 | mockScene = { content: [{ text: 'aaa' }], text: ['aaaa'], tags: {}, choices: [] }; 298 | game.continueStory(); 299 | expect(emit).toHaveBeenCalledWith('game/continueStory'); 300 | expect(mockState.get().scenes).toEqual([processedMockScene]); 301 | game.continueStory(); 302 | expect(mockState.get().scenes).toEqual([processedMockScene]); 303 | }); 304 | 305 | test('scene - autosave mode', () => { 306 | mockState.setKey('metadata', { autosave: true }); 307 | mockScene = { content: [{ text: 'aaa' }], text: ['aaaa'], tags: {}, choices: [] }; 308 | expect(save).not.toHaveBeenCalled(); 309 | game.continueStory(); 310 | expect(emit).toHaveBeenCalledWith('game/continueStory'); 311 | expect(mockState.get().scenes).toEqual([processedMockScene]); 312 | expect(save).toHaveBeenCalledWith({ type: SAVE_AUTOSAVE }); 313 | }); 314 | 315 | test('scene - autosave disabled', () => { 316 | mockState.setKey('metadata', { autosave: false }); 317 | mockScene = { content: [{ text: 'aaa' }], text: ['aaaa'], tags: {}, choices: [] }; 318 | expect(save).not.toHaveBeenCalled(); 319 | game.continueStory(); 320 | expect(emit).toHaveBeenCalledWith('game/continueStory'); 321 | expect(mockState.get().scenes).toEqual([processedMockScene]); 322 | expect(save).not.toHaveBeenCalled(); 323 | }); 324 | 325 | // ============================================================ 326 | // continueStory - tags 327 | // ============================================================ 328 | describe('tags', () => { 329 | let sampleScene; 330 | let processedSampleScene; 331 | beforeEach(() => { 332 | sampleScene = { content: [{ text: 'aaa' }], text: ['aaaa'], tags: {}, choices: [] }; 333 | processedSampleScene = { 334 | ...sampleScene, 335 | content: [{ text: 'aaa', images: [], sounds: [], music: [] }], 336 | images: [], 337 | sounds: [], 338 | music: [] 339 | }; 340 | }); 341 | 342 | test('CLEAR', () => { 343 | mockScene = { ...sampleScene }; 344 | game.continueStory(); 345 | expect(emit).toHaveBeenCalledWith('game/continueStory'); 346 | expect(mockState.get().scenes).toEqual([processedSampleScene]); 347 | game.continueStory(); 348 | expect(mockState.get().scenes).toEqual([processedSampleScene, processedSampleScene]); 349 | mockScene = { ...sampleScene, tags: { CLEAR: true } }; 350 | game.continueStory(); 351 | expect(mockState.get().scenes).toEqual([{ ...processedSampleScene, tags: { CLEAR: true } }]); 352 | }); 353 | 354 | test('AUDIO - start', () => { 355 | const soundFile = 'sound.mp3'; 356 | mockScene = { ...sampleScene, tags: { AUDIO: soundFile } }; 357 | expect(playSound).not.toHaveBeenCalled(); 358 | game.continueStory(); 359 | expect(playSound).toHaveBeenCalledWith(soundFile); 360 | expect(playMusic).not.toHaveBeenCalled(); 361 | expect(emit).toHaveBeenCalledWith('game/handletag', { AUDIO: soundFile }); 362 | }); 363 | 364 | test('AUDIO - stop', () => { 365 | const soundFile = false; 366 | mockScene = { ...sampleScene, tags: { AUDIO: soundFile } }; 367 | expect(stopSound).not.toHaveBeenCalled(); 368 | game.continueStory(); 369 | expect(stopSound).toHaveBeenCalledTimes(1); 370 | expect(stopSound).toHaveBeenCalledWith(); 371 | expect(stopMusic).not.toHaveBeenCalled(); 372 | expect(emit).toHaveBeenCalledWith('game/handletag', { AUDIO: soundFile }); 373 | }); 374 | 375 | test('AUDIO - start multiple files', () => { 376 | const soundFile1 = 'sound.mp3'; 377 | const soundFile2 = 'sound.mp3'; 378 | mockScene = { ...sampleScene, tags: { AUDIO: [soundFile1, soundFile2] } }; 379 | expect(playSound).not.toHaveBeenCalled(); 380 | game.continueStory(); 381 | expect(playSound).toHaveBeenCalledWith([soundFile1, soundFile2]); 382 | expect(playMusic).not.toHaveBeenCalled(); 383 | expect(emit).toHaveBeenCalledWith('game/handletag', { AUDIO: [soundFile1, soundFile2] }); 384 | }); 385 | 386 | test('AUDIOLOOP - start', () => { 387 | const musicFile = 'music.mp3'; 388 | mockScene = { ...sampleScene, tags: { AUDIOLOOP: musicFile } }; 389 | expect(playSingleMusic).not.toHaveBeenCalled(); 390 | game.continueStory(); 391 | expect(playSingleMusic).toHaveBeenCalledWith(musicFile); 392 | expect(playSound).not.toHaveBeenCalled(); 393 | expect(emit).toHaveBeenCalledWith('game/handletag', { AUDIOLOOP: musicFile }); 394 | }); 395 | 396 | test('AUDIOLOOP - stop', () => { 397 | const musicFile = false; 398 | mockScene = { ...sampleScene, tags: { AUDIOLOOP: musicFile } }; 399 | expect(stopMusic).not.toHaveBeenCalled(); 400 | game.continueStory(); 401 | expect(stopMusic).toHaveBeenCalledTimes(1); 402 | expect(stopMusic).toHaveBeenCalledWith(); 403 | expect(stopSound).not.toHaveBeenCalled(); 404 | expect(emit).toHaveBeenCalledWith('game/handletag', { AUDIOLOOP: musicFile }); 405 | }); 406 | 407 | test('PLAY_SOUND', () => { 408 | const soundFile = 'sound.mp3'; 409 | mockScene = { ...sampleScene, tags: { PLAY_SOUND: soundFile } }; 410 | expect(playSound).not.toHaveBeenCalled(); 411 | game.continueStory(); 412 | expect(playSound).toHaveBeenCalledTimes(1); 413 | expect(playSound).toHaveBeenCalledWith(soundFile); 414 | expect(playMusic).not.toHaveBeenCalled(); 415 | expect(emit).toHaveBeenCalledWith('game/handletag', { PLAY_SOUND: soundFile }); 416 | }); 417 | 418 | 419 | test('STOP_SOUND', () => { 420 | const soundFile = 'sound.mp3'; 421 | mockScene = { ...sampleScene, tags: { STOP_SOUND: soundFile } }; 422 | expect(stopSound).not.toHaveBeenCalled(); 423 | game.continueStory(); 424 | expect(stopSound).toHaveBeenCalledTimes(1); 425 | expect(stopSound).toHaveBeenCalledWith(soundFile); 426 | expect(stopMusic).not.toHaveBeenCalled(); 427 | expect(emit).toHaveBeenCalledWith('game/handletag', { STOP_SOUND: soundFile }); 428 | }); 429 | 430 | test('STOP_SOUND - all', () => { 431 | mockScene = { ...sampleScene, tags: { STOP_SOUND: true } }; 432 | expect(stopSound).not.toHaveBeenCalled(); 433 | game.continueStory(); 434 | expect(stopSound).toHaveBeenCalledTimes(1); 435 | expect(stopSound).toHaveBeenCalledWith(); 436 | expect(stopMusic).not.toHaveBeenCalled(); 437 | expect(emit).toHaveBeenCalledWith('game/handletag', { STOP_SOUND: true }); 438 | }); 439 | 440 | test('PLAY_MUSIC', () => { 441 | const musicFile = 'music.mp3'; 442 | mockScene = { ...sampleScene, tags: { PLAY_MUSIC: musicFile } }; 443 | expect(playMusic).not.toHaveBeenCalled(); 444 | game.continueStory(); 445 | expect(playMusic).toHaveBeenCalledTimes(1); 446 | expect(playMusic).toHaveBeenCalledWith(musicFile); 447 | expect(playSound).not.toHaveBeenCalled(); 448 | expect(emit).toHaveBeenCalledWith('game/handletag', { PLAY_MUSIC: musicFile }); 449 | }); 450 | 451 | test('STOP_MUSIC', () => { 452 | const musicFile = 'music.mp3'; 453 | mockScene = { ...sampleScene, tags: { STOP_MUSIC: musicFile } }; 454 | expect(stopMusic).not.toHaveBeenCalled(); 455 | game.continueStory(); 456 | expect(stopMusic).toHaveBeenCalledTimes(1); 457 | expect(stopMusic).toHaveBeenCalledWith(musicFile); 458 | expect(stopSound).not.toHaveBeenCalled(); 459 | expect(emit).toHaveBeenCalledWith('game/handletag', { STOP_MUSIC: musicFile }); 460 | }); 461 | 462 | test('STOP_MUSIC - all', () => { 463 | mockScene = { ...sampleScene, tags: { STOP_MUSIC: true } }; 464 | expect(stopMusic).not.toHaveBeenCalled(); 465 | game.continueStory(); 466 | expect(stopMusic).toHaveBeenCalledTimes(1); 467 | expect(stopMusic).toHaveBeenCalledWith(); 468 | expect(stopSound).not.toHaveBeenCalled(); 469 | expect(emit).toHaveBeenCalledWith('game/handletag', { STOP_MUSIC: true }); 470 | }); 471 | 472 | test('CHECKPOINT - default', () => { 473 | mockScene = { ...sampleScene, tags: { CHECKPOINT: true } }; 474 | expect(save).not.toHaveBeenCalled(); 475 | game.continueStory(); 476 | expect(save).toHaveBeenCalledWith({ type: SAVE_CHECKPOINT, name: true }); 477 | expect(emit).toHaveBeenCalledWith('game/handletag', { CHECKPOINT: true }); 478 | }); 479 | 480 | test('CHECKPOINT - named', () => { 481 | mockScene = { ...sampleScene, tags: { CHECKPOINT: 'point1' } }; 482 | expect(save).not.toHaveBeenCalled(); 483 | game.continueStory(); 484 | expect(save).toHaveBeenCalledWith({ type: SAVE_CHECKPOINT, name: 'point1' }); 485 | expect(emit).toHaveBeenCalledWith('game/handletag', { CHECKPOINT: 'point1' }); 486 | }); 487 | 488 | test('SAVEGAME', () => { 489 | mockScene = { ...sampleScene, tags: { SAVEGAME: 'point2' } }; 490 | expect(save).not.toHaveBeenCalled(); 491 | game.continueStory(); 492 | expect(save).toHaveBeenCalledWith({ type: SAVE_GAME, name: 'point2' }); 493 | expect(emit).toHaveBeenCalledWith('game/handletag', { SAVEGAME: 'point2' }); 494 | }); 495 | 496 | test('RESTART', () => { 497 | mockScene = { ...sampleScene, tags: { RESTART: true } }; 498 | expect(ink.resetStory).not.toHaveBeenCalled(); 499 | // run 500 | game.continueStory(); 501 | // check 502 | expect(ink.resetStory).toHaveBeenCalledTimes(1); 503 | expect(ink.getScene).toHaveBeenCalledTimes(1); // continueStory is not called 504 | expect(emit).toHaveBeenCalledWith('game/restart', { saveSlot: undefined }); 505 | }); 506 | 507 | test('RESTART_FROM_CHECKPOINT', () => { 508 | mockScene = { ...sampleScene, tags: { RESTART_FROM_CHECKPOINT: 'test_checkpoint' } }; 509 | expect(ink.resetStory).not.toHaveBeenCalled(); 510 | expect(load).not.toHaveBeenCalled(); 511 | // run 512 | game.continueStory(); 513 | // check 514 | expect(load).not.toHaveBeenCalled(); 515 | expect(ink.resetStory).toHaveBeenCalledTimes(1); 516 | expect(ink.getScene).toHaveBeenCalledTimes(1); // continueStory is not called 517 | expect(emit).toHaveBeenCalledWith( 518 | 'game/restart', 519 | { saveSlot: getSaveSlotKey({ type: SAVE_CHECKPOINT, name: 'test_checkpoint' }) } 520 | ); 521 | }); 522 | 523 | test('custom scene processor', () => { 524 | mockScene = { ...sampleScene, tags: { CUSTOMTAG: 'test' } }; 525 | const targetScene = { ...processedSampleScene, tags: { CUSTOMTAG: 'test' }, customtag: 'test' }; 526 | const mockProcessor = jest.fn((s) => { s.customtag = s.tags.CUSTOMTAG; }); 527 | game.defineSceneProcessor(mockProcessor); 528 | game.continueStory(); 529 | expect(mockState.get().scenes).toEqual([targetScene]); 530 | expect(emit).not.toHaveBeenCalledWith('game/handletag', expect.any(Object)); 531 | }); 532 | }); 533 | }); 534 | 535 | // ============================================================ 536 | // other methods 537 | // ============================================================ 538 | test('getAssetPath', () => { 539 | const asset = 'aaa'; 540 | expect(mockGetAssetPath).not.toHaveBeenCalled(); 541 | game.getAssetPath(asset); 542 | expect(mockGetAssetPath).toHaveBeenCalledWith(asset); 543 | }); 544 | 545 | test('makeChoice', () => { 546 | const id = 1; 547 | expect(ink.makeChoice).not.toHaveBeenCalled(); 548 | game.makeChoice(id); 549 | expect(ink.makeChoice).toHaveBeenCalledWith(id); 550 | }); 551 | 552 | test('load', async () => { 553 | const id = 'saveID'; 554 | await game.saveGame(id); 555 | expect(load).not.toHaveBeenCalled(); 556 | await game.load({ type: SAVE_GAME, name: id }); 557 | expect(load).toHaveBeenCalledTimes(1); 558 | expect(load).toHaveBeenCalledWith({ type: SAVE_GAME, name: id }); 559 | }); 560 | 561 | test('saveGame', async () => { 562 | const id = 'saveID'; 563 | expect(save).not.toHaveBeenCalled(); 564 | await game.saveGame(id); 565 | expect(save).toHaveBeenCalledTimes(1); 566 | expect(save).toHaveBeenCalledWith({ type: SAVE_GAME, name: id }); 567 | }); 568 | 569 | test('saveCheckpoint', async () => { 570 | const id = 'saveID'; 571 | expect(save).not.toHaveBeenCalled(); 572 | await game.saveCheckpoint(id); 573 | expect(save).toHaveBeenCalledTimes(1); 574 | expect(save).toHaveBeenCalledWith({ type: SAVE_CHECKPOINT, name: id }); 575 | }); 576 | 577 | test('saveAutosave', async () => { 578 | expect(save).not.toHaveBeenCalled(); 579 | await game.saveAutosave(); 580 | expect(save).toHaveBeenCalledTimes(1); 581 | expect(save).toHaveBeenCalledWith({ type: SAVE_AUTOSAVE }); 582 | }); 583 | 584 | 585 | test('listSaves', async () => { 586 | expect(listSaves).not.toHaveBeenCalled(); 587 | await game.listSaves(); 588 | expect(listSaves).toHaveBeenCalledTimes(1); 589 | }); 590 | 591 | test('removeSave', async () => { 592 | const id = 'saveSlot'; 593 | expect(removeSave).not.toHaveBeenCalled(); 594 | await game.removeSave(id); 595 | expect(removeSave).toHaveBeenCalledTimes(1); 596 | expect(removeSave).toHaveBeenCalledWith(id); 597 | }); 598 | 599 | test('existSave', async () => { 600 | expect(existSave).not.toHaveBeenCalled(); 601 | const x = await game.existSave('someSave'); 602 | expect(existSave).toHaveBeenCalledTimes(1); 603 | expect(x).toBe(false); 604 | }); 605 | }); 606 | -------------------------------------------------------------------------------- /__tests__/components/game/sessions.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import mockPersistent from '../../../__mocks__/persistent'; 3 | import mockState from '../../../__mocks__/state'; 4 | 5 | import { getSession, setSession, getSessions, removeSession } from '../../../src/components/game/sessions'; 6 | 7 | import { emit } from '../../../src/utils/emitter'; 8 | 9 | import { 10 | save, 11 | SAVE_GAME 12 | } from '../../../src/components/saves'; 13 | 14 | jest.mock('../../../src/utils/emitter', () => ({ 15 | emit: jest.fn() 16 | })); 17 | 18 | jest.mock('../../../src/components/ink', () => ({ 19 | getState: jest.fn(() => ({ inkjson: 'content' })) 20 | })); 21 | 22 | jest.mock('../../../src/utils/interfaces', () => ({ 23 | interfaces: jest.fn(() => ({ 24 | state: mockState, 25 | persistent: mockPersistent 26 | })) 27 | })); 28 | 29 | beforeEach(() => { 30 | mockState.reset(); 31 | mockPersistent.reset(); 32 | jest.clearAllMocks(); 33 | mockPersistent.init('myapp-test'); 34 | mockState.setKey('game', { $gameUUID: 'test-game' }); 35 | mockState.setKey('scenes', []); 36 | mockState.setKey('vars', {}); 37 | }); 38 | 39 | 40 | 41 | describe('components/sessions', () => { 42 | test('get/set session', () => { 43 | // default 44 | let session; 45 | session = getSession(); 46 | expect(session).toBe(''); 47 | // set 48 | setSession('session'); 49 | session = getSession(); 50 | expect(session).toBe('session'); 51 | expect(emit).toHaveBeenCalledWith('game/setSession', 'session'); 52 | emit.mockClear(); 53 | // set number 54 | setSession(0); 55 | session = getSession(); 56 | expect(session).toBe(0); 57 | expect(emit).toHaveBeenCalledWith('game/setSession', 0); 58 | emit.mockClear(); 59 | // reset 60 | setSession(null); 61 | session = getSession(); 62 | expect(session).toBe(''); 63 | expect(emit).toHaveBeenCalledWith('game/setSession', ''); 64 | }); 65 | 66 | describe('getSessions', () => { 67 | test('empty', async () => { 68 | // run 69 | const sessions = await getSessions(); 70 | // check 71 | expect(sessions).toEqual({}); 72 | expect(emit).toHaveBeenCalledWith('game/getSessions', {}); 73 | }); 74 | 75 | test('single default session', async () => { 76 | // prepare 77 | save({ type: SAVE_GAME, name: 'save1' }); 78 | save({ type: SAVE_GAME, name: 'save2' }); 79 | const expectedSessions = { '': 2 }; 80 | // run 81 | const sessions = await getSessions(); 82 | // check 83 | expect(sessions).toEqual(expectedSessions); 84 | expect(emit).toHaveBeenCalledWith('game/getSessions', expectedSessions); 85 | }); 86 | 87 | test('multiple sessions', async () => { 88 | // prepare 89 | save({ type: SAVE_GAME, name: 'save1' }); 90 | save({ type: SAVE_GAME, name: 'save2' }); 91 | setSession('session1'); 92 | save({ type: SAVE_GAME, name: 'save1' }); 93 | setSession('session2'); 94 | save({ type: SAVE_GAME, name: 'save1' }); 95 | save({ type: SAVE_GAME, name: 'save2' }); 96 | save({ type: SAVE_GAME, name: 'save3' }); 97 | const expectedSessions = { 98 | '': 2, 99 | session1: 1, 100 | session2: 3 101 | }; 102 | // run 103 | const sessions = await getSessions(); 104 | // check 105 | expect(sessions).toEqual(expectedSessions); 106 | expect(emit).toHaveBeenCalledWith('game/getSessions', expectedSessions); 107 | }); 108 | 109 | test('exclude settings and persistent', async () => { 110 | // prepare 111 | save({ type: SAVE_GAME, name: 'save1' }); 112 | save({ type: SAVE_GAME, name: 'save2' }); 113 | mockPersistent.set('settings', { settings: true }); 114 | mockPersistent.set('persist', { persist: true }); 115 | const expectedSessions = { '': 2 }; 116 | // run 117 | const sessions = await getSessions(); 118 | // check 119 | expect(sessions).toEqual(expectedSessions); 120 | expect(emit).toHaveBeenCalledWith('game/getSessions', expectedSessions); 121 | }); 122 | }); 123 | 124 | describe('deleteSession', () => { 125 | test('empty', async () => { 126 | // run 127 | await removeSession(); 128 | // check 129 | const sessions = await getSessions(); 130 | expect(sessions).toEqual({}); 131 | expect(emit).toHaveBeenCalledWith('game/deleteSession', ''); 132 | }); 133 | 134 | test('single default session', async () => { 135 | // prepare 136 | save({ type: SAVE_GAME, name: 'save1' }); 137 | save({ type: SAVE_GAME, name: 'save2' }); 138 | const expectedSessions = {}; 139 | // run 140 | await removeSession(); 141 | // check 142 | const sessions = await getSessions(); 143 | expect(sessions).toEqual(expectedSessions); 144 | expect(emit).toHaveBeenCalledWith('game/deleteSession', ''); 145 | }); 146 | 147 | test('multiple sessions', async () => { 148 | // prepare 149 | save({ type: SAVE_GAME, name: 'save1' }); 150 | save({ type: SAVE_GAME, name: 'save2' }); 151 | setSession('session1'); 152 | save({ type: SAVE_GAME, name: 'save1' }); 153 | setSession('session2'); 154 | save({ type: SAVE_GAME, name: 'save1' }); 155 | save({ type: SAVE_GAME, name: 'save2' }); 156 | save({ type: SAVE_GAME, name: 'save3' }); 157 | const expectedSessions = { 158 | '': 2, 159 | session2: 3 160 | }; 161 | // run 162 | await removeSession('session1'); 163 | // check 164 | const sessions = await getSessions(); 165 | expect(sessions).toEqual(expectedSessions); 166 | expect(emit).toHaveBeenCalledWith('game/deleteSession', 'session1'); 167 | }); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /__tests__/components/ink.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import { emit } from '../../src/utils/emitter'; 3 | import { setConfig } from '../../src/utils/config'; 4 | 5 | import ink from '../../src/components/ink'; 6 | 7 | jest.mock('../../src/utils/emitter', () => ({ 8 | emit: jest.fn() 9 | })); 10 | 11 | jest.useFakeTimers(); 12 | 13 | const mockInkStoryInstance = { 14 | content: '', 15 | sceneCounter: 1, 16 | globalTags: ['title: test story', 'autosave', 'single_scene', 'observe: var1'], 17 | state: { 18 | LoadJson: jest.fn(), 19 | toJson: jest.fn(() => ({ inkstate: 'jsonStructure' })) 20 | }, 21 | onError: () => {}, 22 | canContinue: true, 23 | currentChoices: [], 24 | Continue() { 25 | if (this.sceneCounter === 1) { 26 | this.currentText = '\n'; 27 | } else { 28 | this.currentText = `Paragraph ${this.sceneCounter}.`; 29 | } 30 | this.currentTags = ['HELLO', 'WORLD']; 31 | this.sceneCounter += 1; 32 | if (this.sceneCounter > 3) { 33 | this.canContinue = false; 34 | this.currentChoices = [ 35 | { text: 'Option 1' }, 36 | { text: 'Option 2', tags: ['CHOICE', 'TEST: optional'] } 37 | ]; 38 | } 39 | }, 40 | ChooseChoiceIndex() { 41 | this.currentChoices = []; 42 | this.canContinue = true; 43 | }, 44 | VisitCountAtPathString() { 45 | return 5; 46 | }, 47 | EvaluateFunction(fn, args) { 48 | return `${fn} result: ${args[0] + args[1]}`; 49 | }, 50 | variablesState: { 51 | var1: 'var1-value', 52 | var2: 'var2-value' 53 | }, 54 | ObserveVariable() { 55 | return true; 56 | }, 57 | ChoosePathString() { 58 | return true; 59 | }, 60 | ResetState() { 61 | return true; 62 | } 63 | }; 64 | 65 | const inkVars = Object.keys(mockInkStoryInstance.variablesState); 66 | const globalVars = new Map(); 67 | inkVars.forEach((k) => globalVars.set(k, true)); 68 | mockInkStoryInstance.variablesState._globalVariables = globalVars; /* eslint-disable-line no-underscore-dangle */ 69 | 70 | const MockInkStory = jest.fn((content) => { 71 | mockInkStoryInstance.content = content; 72 | return mockInkStoryInstance; 73 | }); 74 | 75 | const spyContinue = jest.spyOn(mockInkStoryInstance, 'Continue'); 76 | const spyChooseChoiceIndex = jest.spyOn(mockInkStoryInstance, 'ChooseChoiceIndex'); 77 | const spyVisitCountAtPathString = jest.spyOn(mockInkStoryInstance, 'VisitCountAtPathString'); 78 | const spyEvaluateFunction = jest.spyOn(mockInkStoryInstance, 'EvaluateFunction'); 79 | const spyObserveVariable = jest.spyOn(mockInkStoryInstance, 'ObserveVariable'); 80 | const spyChoosePathString = jest.spyOn(mockInkStoryInstance, 'ChoosePathString'); 81 | const spyResetState = jest.spyOn(mockInkStoryInstance, 'ResetState'); 82 | 83 | 84 | beforeEach(() => { 85 | jest.clearAllMocks(); 86 | mockInkStoryInstance.content = ''; 87 | mockInkStoryInstance.currentChoices = []; 88 | mockInkStoryInstance.sceneCounter = 1; 89 | mockInkStoryInstance.canContinue = true; 90 | }); 91 | 92 | describe('components/ink', () => { 93 | test('initStory', () => { 94 | const content = 'ink json'; 95 | setConfig(MockInkStory, { applicationID: 'testAppID' }); 96 | ink.initStory(content); 97 | const story = ink.story(); 98 | expect(MockInkStory).toHaveBeenCalledWith(content); 99 | expect(story).toEqual(mockInkStoryInstance); 100 | expect(emit).toHaveBeenCalledWith('ink/initStory'); 101 | }); 102 | 103 | test('resetStory', () => { 104 | expect(spyResetState).toHaveBeenCalledTimes(0); 105 | ink.resetStory(); 106 | expect(spyResetState).toHaveBeenCalledTimes(1); 107 | expect(emit).toHaveBeenCalledWith('ink/resetStory', true); 108 | }); 109 | 110 | test('loadState', () => { 111 | expect(mockInkStoryInstance.state.LoadJson).not.toHaveBeenCalled(); 112 | ink.loadState('saved-state'); 113 | expect(mockInkStoryInstance.state.LoadJson).toHaveBeenCalledWith('saved-state'); 114 | }); 115 | 116 | test('getState', () => { 117 | expect(mockInkStoryInstance.state.toJson).not.toHaveBeenCalled(); 118 | const state = ink.getState('saved-state'); 119 | expect(state).toEqual({ inkstate: 'jsonStructure' }); 120 | expect(mockInkStoryInstance.state.toJson).toHaveBeenCalledTimes(1); 121 | }); 122 | 123 | test('makeChoice', () => { 124 | const choiceId = '1'; 125 | expect(spyChooseChoiceIndex).not.toHaveBeenCalled(); 126 | ink.makeChoice(choiceId); 127 | expect(spyChooseChoiceIndex).toHaveBeenCalledWith(choiceId); 128 | expect(emit).toHaveBeenCalledWith('ink/makeChoice', choiceId); 129 | }); 130 | 131 | test('getVisitCount', () => { 132 | expect(spyVisitCountAtPathString).not.toHaveBeenCalled(); 133 | const count = ink.getVisitCount('ref'); 134 | expect(count).toEqual(5); 135 | expect(spyVisitCountAtPathString).toHaveBeenCalledWith('ref'); 136 | expect(emit).toHaveBeenCalledWith('ink/getVisitCount', { ref: 'ref', visitCount: 5 }); 137 | }); 138 | 139 | test('evaluateFunction', () => { 140 | expect(spyEvaluateFunction).not.toHaveBeenCalled(); 141 | const result = ink.evaluateFunction('testfunction', [3, 4]); 142 | expect(result).toEqual('testfunction result: 7'); 143 | expect(spyEvaluateFunction).toHaveBeenCalledWith('testfunction', [3, 4], undefined); 144 | expect(emit).toHaveBeenCalledWith( 145 | 'ink/evaluateFunction', 146 | { function: 'testfunction', args: [3, 4], result: 'testfunction result: 7' } 147 | ); 148 | }); 149 | 150 | test('getGlobalTags', () => { 151 | const globaltags = ink.getGlobalTags(); 152 | const expectedGlobalTags = { 153 | single_scene: true, 154 | autosave: true, 155 | observe: 'var1', 156 | title: 'test story' 157 | }; 158 | expect(globaltags).toEqual(expectedGlobalTags); 159 | expect(emit).toHaveBeenCalledWith('ink/getGlobalTags', expectedGlobalTags); 160 | }); 161 | 162 | test('getVariable', () => { 163 | const expectedValue = mockInkStoryInstance.variablesState.var1; 164 | const result = ink.getVariable('var1'); 165 | expect(result).toEqual(expectedValue); 166 | expect(emit).toHaveBeenCalledWith('ink/getVariable', { name: 'var1', value: expectedValue }); 167 | }); 168 | 169 | test('getVariables', () => { 170 | const expectedValue = { 171 | var1: 'var1-value', 172 | var2: 'var2-value' 173 | }; 174 | const result = ink.getVariables(); 175 | expect(result).toEqual(expectedValue); 176 | expect(emit).toHaveBeenCalledWith('ink/getVariables', result); 177 | }); 178 | 179 | test('setVariable', () => { 180 | ink.setVariable('varTest', 10); 181 | const expectedValue = mockInkStoryInstance.variablesState.varTest; 182 | expect(expectedValue).toEqual(10); 183 | expect(emit).toHaveBeenCalledWith('ink/setVariable', { name: 'varTest', value: 10 }); 184 | }); 185 | 186 | test('observeVariable', () => { 187 | expect(spyObserveVariable).not.toHaveBeenCalled(); 188 | const handler = () => 'var-handler'; 189 | ink.observeVariable('var1', handler); 190 | expect(spyObserveVariable).toHaveBeenCalledWith('var1', handler); 191 | }); 192 | 193 | test('goTo', () => { 194 | expect(spyChoosePathString).not.toHaveBeenCalled(); 195 | const knot = 'knotAddress'; 196 | ink.goTo(knot); 197 | expect(spyChoosePathString).toHaveBeenCalledWith(knot); 198 | expect(emit).toHaveBeenCalledWith('ink/goTo', knot); 199 | }); 200 | 201 | test('onError', () => { 202 | const errorEvent = { error: 'Internal error' }; 203 | let checkErrorEvent = null; 204 | ink.onError((error) => { checkErrorEvent = error; }); 205 | // check 206 | expect(checkErrorEvent).not.toEqual(errorEvent); 207 | ink.story().onError(errorEvent); 208 | expect(checkErrorEvent).toEqual(errorEvent); 209 | expect(emit).toHaveBeenCalledWith('ink/onError', errorEvent); 210 | }); 211 | 212 | test('getScene', () => { 213 | expect(spyContinue).not.toHaveBeenCalled(); 214 | const expectedScene = { 215 | content: [ 216 | { 217 | text: '\n', 218 | tags: { HELLO: true, WORLD: true } 219 | }, 220 | { 221 | text: 'Paragraph 2.', 222 | tags: { HELLO: true, WORLD: true } 223 | } 224 | ], 225 | text: [ 226 | '\n', 227 | 'Paragraph 2.' 228 | ], 229 | tags: { 230 | HELLO: true, 231 | WORLD: true 232 | }, 233 | canContinue: true, // in "continue" mode, adds canContinue flag 234 | choices: [], 235 | uuid: jest.now() // equivalent to Date.now() 236 | }; 237 | const scene = ink.getScene(); 238 | expect(spyContinue).toHaveBeenCalledTimes(2); // first paragraph has "\n" 239 | expect(scene).toEqual(expectedScene); 240 | expect(emit).toHaveBeenCalledWith('ink/getScene', scene); 241 | }); 242 | 243 | test('getScene - maximal continue', () => { 244 | expect(spyContinue).not.toHaveBeenCalled(); 245 | const expectedScene = { 246 | content: [ 247 | { 248 | text: '\n', 249 | tags: { HELLO: true, WORLD: true } 250 | }, 251 | { 252 | text: 'Paragraph 2.', 253 | tags: { HELLO: true, WORLD: true } 254 | }, 255 | { 256 | text: 'Paragraph 3.', 257 | tags: { HELLO: true, WORLD: true } 258 | } 259 | ], 260 | text: [ 261 | '\n', 262 | 'Paragraph 2.', 263 | 'Paragraph 3.' 264 | ], 265 | tags: { 266 | HELLO: true, 267 | WORLD: true 268 | }, 269 | choices: [ 270 | { id: 0, choice: 'Option 1', tags: {} }, 271 | { id: 1, choice: 'Option 2', tags: { CHOICE: true, TEST: 'optional' } } 272 | ], 273 | uuid: jest.now() // equivalent to Date.now() 274 | }; 275 | const scene = ink.getScene(true); 276 | expect(spyContinue).toHaveBeenCalledTimes(3); 277 | expect(scene).toEqual(expectedScene); 278 | expect(emit).toHaveBeenCalledWith('ink/getScene', scene); 279 | }); 280 | }); 281 | -------------------------------------------------------------------------------- /__tests__/components/saves.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import mockPersistent from '../../__mocks__/persistent'; 3 | import mockState from '../../__mocks__/state'; 4 | 5 | import { emit } from '../../src/utils/emitter'; 6 | import { setSession } from '../../src/components/game/sessions'; 7 | import ink from '../../src/components/ink'; 8 | import { 9 | getSaveSlotKey, 10 | load, 11 | save, 12 | existSave, 13 | removeSave, 14 | listSaves, 15 | SAVE_GAME, 16 | SAVE_AUTOSAVE, 17 | SAVE_CHECKPOINT 18 | } from '../../src/components/saves'; 19 | 20 | jest.mock('../../src/utils/emitter', () => ({ 21 | emit: jest.fn() 22 | })); 23 | 24 | jest.mock('../../src/components/ink', () => ({ 25 | loadState: jest.fn(), 26 | getState: jest.fn(() => ({ inkjson: 'content' })), 27 | getVariable: jest.fn((v) => `${v}-value`) 28 | })); 29 | 30 | jest.mock('../../src/utils/interfaces', () => ({ 31 | interfaces: jest.fn(() => ({ 32 | state: mockState, 33 | persistent: mockPersistent 34 | })) 35 | })); 36 | 37 | jest 38 | .useFakeTimers() 39 | .setSystemTime(new Date('2023-08-09')); 40 | 41 | beforeEach(() => { 42 | mockState.reset(); 43 | mockPersistent.reset(); 44 | jest.clearAllMocks(); 45 | mockPersistent.init('myapp-test'); 46 | mockState.setKey('game', { $gameUUID: 'test-game' }); 47 | mockState.setKey('scenes', []); 48 | mockState.setKey('vars', {}); 49 | }); 50 | 51 | describe('components/saves', () => { 52 | describe('getSaveSlotKey', () => { 53 | test('default', () => { 54 | const slotName = getSaveSlotKey({ type: 'autosave' }); 55 | expect(slotName).toBe(`test-game//save/${SAVE_AUTOSAVE}/`); 56 | }); 57 | test('default - named', () => { 58 | const slotName = getSaveSlotKey({ type: 'checkpoint', name: 'stage1' }); 59 | expect(slotName).toBe(`test-game//save/${SAVE_CHECKPOINT}/stage1`); 60 | }); 61 | test('default - numbered', () => { 62 | const slotName = getSaveSlotKey({ type: 'checkpoint', name: 1 }); 63 | expect(slotName).toBe(`test-game//save/${SAVE_CHECKPOINT}/1`); 64 | }); 65 | test('default - untyped', () => { 66 | const slotName = getSaveSlotKey({ type: true, name: 'check' }); 67 | expect(slotName).toBe(`test-game//save/${SAVE_GAME}/check`); 68 | }); 69 | test('default - unnamed', () => { 70 | const slotName = getSaveSlotKey({ type: 'checkpoint', name: true }); 71 | expect(slotName).toBe(`test-game//save/${SAVE_CHECKPOINT}/`); 72 | }); 73 | test('session', () => { 74 | mockState.setSubkey('game', '$sessionID', 's1'); 75 | const slotName = getSaveSlotKey({ type: 'autosave' }); 76 | expect(slotName).toBe(`test-game/s1/save/${SAVE_AUTOSAVE}/`); 77 | }); 78 | test('session - named', () => { 79 | mockState.setSubkey('game', '$sessionID', 's1'); 80 | const slotName = getSaveSlotKey({ type: 'checkpoint', name: 'stage1' }); 81 | expect(slotName).toBe(`test-game/s1/save/${SAVE_CHECKPOINT}/stage1`); 82 | }); 83 | }); 84 | 85 | describe('save', () => { 86 | test('save game state', async () => { 87 | const mockScenes = ['scene1', 'scene2']; 88 | mockState.setKey('scenes', mockScenes); 89 | const type = 'checkpoint'; 90 | const name = 'stage2'; 91 | // run 92 | await save({ type, name }); 93 | // check 94 | expect(ink.getState).toHaveBeenCalledTimes(1); 95 | const saveID = getSaveSlotKey({ type, name }); 96 | const saved = await mockPersistent.get(saveID); 97 | expect(saved).toEqual({ 98 | name, 99 | type, 100 | date: 1691539200000, 101 | game: mockState.get().game, 102 | scenes: mockScenes, 103 | state: { inkjson: 'content' } 104 | }); 105 | expect(emit).toHaveBeenCalledWith('game/save', saveID); 106 | }); 107 | }); 108 | 109 | 110 | describe('load', () => { 111 | test('load game state', async () => { 112 | const mockScenes = ['scene1', 'scene2']; 113 | mockState.setKey('scenes', mockScenes); 114 | const type = 'checkpoint'; 115 | const name = 'stage2'; 116 | // run 117 | await save({ type, name }); 118 | emit.mockClear(); 119 | // load 120 | const saveID = getSaveSlotKey({ type, name }); 121 | await load(saveID); 122 | expect(ink.loadState).toHaveBeenCalledWith({ inkjson: 'content' }); 123 | expect(mockState.get().scenes).toEqual(mockScenes); 124 | expect(mockState.get().game).toEqual({ $gameUUID: 'test-game' }); 125 | expect(mockState.get().vars).toEqual({}); 126 | expect(emit).toHaveBeenCalledWith('game/load', saveID); 127 | }); 128 | }); 129 | 130 | describe('exist', () => { 131 | test('game state exists', async () => { 132 | const type = 'checkpoint'; 133 | const name = 'stage2'; 134 | const saveID = getSaveSlotKey({ type, name }); 135 | // check 136 | let saveExists = await existSave(saveID); 137 | expect(saveExists).toBe(false); 138 | await save({ name, type }); 139 | saveExists = await existSave(saveID); 140 | expect(saveExists).toBe(true); 141 | await removeSave(saveID); 142 | saveExists = await existSave(saveID); 143 | expect(saveExists).toBe(false); 144 | }); 145 | }); 146 | 147 | describe('listSaves', () => { 148 | test('get saves for specific game', async () => { 149 | mockState.setSubkey('game', '$gameUUID', 'UUID1'); 150 | await save({ type: 'game', name: 'game1_save1' }); 151 | await save({ type: 'checkpoint', name: 'game1_save2' }); 152 | await save({ type: 'autosave' }); 153 | let savesList = await listSaves(); 154 | expect(savesList).toEqual([{ 155 | id: getSaveSlotKey({ type: 'game', name: 'game1_save1' }), 156 | name: 'game1_save1', 157 | type: 'game', 158 | game: { $gameUUID: 'UUID1' }, 159 | date: 1691539200000 160 | }, { 161 | id: getSaveSlotKey({ type: 'checkpoint', name: 'game1_save2' }), 162 | name: 'game1_save2', 163 | type: 'checkpoint', 164 | game: { $gameUUID: 'UUID1' }, 165 | date: 1691539200000 166 | }, { 167 | id: getSaveSlotKey({ type: 'autosave' }), 168 | type: 'autosave', 169 | game: { $gameUUID: 'UUID1' }, 170 | date: 1691539200000 171 | }]); 172 | // switch to another game 173 | mockState.setSubkey('game', '$gameUUID', 'UUID2'); 174 | savesList = await listSaves(); 175 | expect(savesList).toEqual([]); 176 | expect(emit).toHaveBeenCalledWith('game/listSaves', []); 177 | // save something under new UUID 178 | await save({ type: 'checkpoint', name: 'game2_checkpoint' }); 179 | savesList = await listSaves(); 180 | expect(savesList).toEqual([{ 181 | id: getSaveSlotKey({ type: 'checkpoint', name: 'game2_checkpoint' }), 182 | name: 'game2_checkpoint', 183 | type: 'checkpoint', 184 | game: { $gameUUID: 'UUID2' }, 185 | date: 1691539200000 186 | }]); 187 | expect(emit).toHaveBeenCalledWith('game/listSaves', savesList); 188 | // back to original game 189 | mockState.setSubkey('game', '$gameUUID', 'UUID1'); 190 | savesList = await listSaves(); 191 | expect(savesList).toEqual([{ 192 | id: getSaveSlotKey({ type: 'game', name: 'game1_save1' }), 193 | name: 'game1_save1', 194 | type: 'game', 195 | game: { $gameUUID: 'UUID1' }, 196 | date: 1691539200000 197 | }, { 198 | id: getSaveSlotKey({ type: 'checkpoint', name: 'game1_save2' }), 199 | name: 'game1_save2', 200 | type: 'checkpoint', 201 | game: { $gameUUID: 'UUID1' }, 202 | date: 1691539200000 203 | }, { 204 | id: getSaveSlotKey({ type: 'autosave' }), 205 | type: 'autosave', 206 | game: { $gameUUID: 'UUID1' }, 207 | date: 1691539200000 208 | }]); 209 | expect(emit).toHaveBeenCalledWith('game/listSaves', savesList); 210 | }); 211 | 212 | test('list saves for specific session', async () => { 213 | mockState.setSubkey('game', '$gameUUID', 'UUID1'); 214 | await save({ type: 'game', name: 'game1_save1' }); 215 | await save({ type: 'checkpoint', name: 'game1_save2' }); 216 | setSession('session1'); 217 | await save({ type: 'game', name: 'game1_session1_save1' }); 218 | await save({ type: 'checkpoint', name: 'game1_session1_save2' }); 219 | setSession('session2'); 220 | await save({ type: 'game', name: 'game1_session2_save1' }); 221 | await save({ type: 'checkpoint', name: 'game1_session2_save2' }); 222 | let savesList; 223 | // check saves for default session 224 | setSession(''); 225 | savesList = await listSaves(); 226 | expect(savesList).toEqual([{ 227 | id: getSaveSlotKey({ type: 'game', name: 'game1_save1' }), 228 | name: 'game1_save1', 229 | type: 'game', 230 | game: { $gameUUID: 'UUID1' }, 231 | date: 1691539200000 232 | }, { 233 | id: getSaveSlotKey({ type: 'checkpoint', name: 'game1_save2' }), 234 | name: 'game1_save2', 235 | type: 'checkpoint', 236 | game: { $gameUUID: 'UUID1' }, 237 | date: 1691539200000 238 | }]); 239 | // check saves for session1 240 | setSession('session1'); 241 | savesList = await listSaves(); 242 | expect(savesList).toEqual([{ 243 | id: getSaveSlotKey({ type: 'game', name: 'game1_session1_save1' }), 244 | name: 'game1_session1_save1', 245 | type: 'game', 246 | game: { $gameUUID: 'UUID1', $sessionID: 'session1' }, 247 | date: 1691539200000 248 | }, { 249 | id: getSaveSlotKey({ type: 'checkpoint', name: 'game1_session1_save2' }), 250 | name: 'game1_session1_save2', 251 | type: 'checkpoint', 252 | game: { $gameUUID: 'UUID1', $sessionID: 'session1' }, 253 | date: 1691539200000 254 | }]); 255 | }); 256 | }); 257 | }); 258 | -------------------------------------------------------------------------------- /__tests__/components/settings.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import mockPersistent from '../../__mocks__/persistent'; 3 | import mockState from '../../__mocks__/state'; 4 | import { emit } from '../../src/utils/emitter'; 5 | 6 | import settings from '../../src/components/settings'; 7 | import { setConfig } from '../../src/utils/config'; 8 | 9 | const mockSound = { 10 | mute: jest.fn(), 11 | setVolume: jest.fn() 12 | }; 13 | 14 | jest.mock('../../src/utils/emitter', () => ({ 15 | emit: jest.fn() 16 | })); 17 | 18 | jest.mock('../../src/utils/interfaces', () => ({ 19 | interfaces: jest.fn(() => ({ 20 | state: mockState, 21 | persistent: mockPersistent, 22 | sound: mockSound 23 | })) 24 | })); 25 | 26 | const defaultConfig = { 27 | applicationID: '!CHANGE_THIS', 28 | settings: { 29 | volume: 0, 30 | mute: true 31 | } 32 | }; 33 | 34 | const Story = () => ({ inkStoryConstructor: true }); 35 | 36 | beforeEach(() => { 37 | mockState.reset(); 38 | mockPersistent.reset(); 39 | jest.clearAllMocks(); 40 | setConfig(Story, defaultConfig); 41 | }); 42 | 43 | 44 | describe('components/settings', () => { 45 | describe('load', () => { 46 | test('default settings', async () => { 47 | await settings.load(); 48 | expect(mockState.get().settings).toEqual({ 49 | mute: true, 50 | volume: 0 51 | }); 52 | expect(mockSound.mute).toHaveBeenCalledWith(true); 53 | expect(mockSound.setVolume).toHaveBeenCalledWith(0); 54 | expect(emit).toHaveBeenCalledWith('settings/load', { 55 | mute: true, 56 | volume: 0 57 | }); 58 | }); 59 | 60 | test('settings from config', async () => { 61 | setConfig(Story, { 62 | applicationID: 'test-app', 63 | settings: { 64 | mute: false, 65 | volume: 5, 66 | fullscreen: false, 67 | fontsize: 10 68 | } 69 | }); 70 | await settings.load(); 71 | expect(mockState.get().settings).toEqual({ 72 | mute: false, 73 | volume: 5, 74 | fullscreen: false, 75 | fontsize: 10 76 | }); 77 | expect(mockSound.mute).toHaveBeenCalledWith(false); 78 | expect(mockSound.setVolume).toHaveBeenCalledWith(5); 79 | }); 80 | 81 | test('settings from persistent - with new defaults', async () => { 82 | const defaultConfig2 = { 83 | applicationID: '!CHANGE_THIS', 84 | settings: { 85 | volume: 0, 86 | mute: true, 87 | fontFamily: 'System' 88 | } 89 | }; 90 | setConfig(Story, defaultConfig2); 91 | const savedSettings = { 92 | mute: false, 93 | volume: 10, 94 | fullscreen: true, 95 | fontsize: 20 96 | }; 97 | const expectedSettings = { 98 | ...savedSettings, 99 | fontFamily: 'System' 100 | }; 101 | await mockPersistent.set('settings', savedSettings); 102 | await settings.load(); 103 | expect(mockState.get().settings).toEqual(expectedSettings); 104 | expect(mockSound.mute).toHaveBeenCalledWith(false); 105 | expect(mockSound.setVolume).toHaveBeenCalledWith(10); 106 | expect(emit).toHaveBeenCalledWith('settings/load', expectedSettings); 107 | }); 108 | }); 109 | 110 | test('settings from persistent', async () => { 111 | const savedSettings = { 112 | mute: false, 113 | volume: 10, 114 | fullscreen: true, 115 | fontsize: 20 116 | }; 117 | await mockPersistent.set('settings', savedSettings); 118 | await settings.load(); 119 | expect(mockState.get().settings).toEqual(savedSettings); 120 | expect(mockSound.mute).toHaveBeenCalledWith(false); 121 | expect(mockSound.setVolume).toHaveBeenCalledWith(10); 122 | expect(emit).toHaveBeenCalledWith('settings/load', savedSettings); 123 | }); 124 | 125 | describe('save', () => { 126 | test('save settings', async () => { 127 | const appSettings = { 128 | mute: false, 129 | volume: 5, 130 | fullscreen: false, 131 | fontsize: 15 132 | }; 133 | mockState.setKey('settings', appSettings); 134 | await settings.save(); 135 | const savedSettings = await mockPersistent.get('settings'); 136 | expect(savedSettings).toEqual(appSettings); 137 | expect(emit).toHaveBeenCalledWith('settings/save', savedSettings); 138 | }); 139 | }); 140 | 141 | describe('get', () => { 142 | test('get setting', () => { 143 | const appSettings = { 144 | mute: false, 145 | volume: 5, 146 | fullscreen: false, 147 | fontsize: 15 148 | }; 149 | mockState.setKey('settings', appSettings); 150 | const fontsize = settings.get('fontsize'); 151 | expect(fontsize).toBe(appSettings.fontsize); 152 | const mute = settings.get('mute'); 153 | expect(mute).toBe(appSettings.mute); 154 | expect(emit).toHaveBeenCalledWith('settings/get', { name: 'mute', value: appSettings.mute }); 155 | }); 156 | }); 157 | 158 | describe('toggle', () => { 159 | test('toggle setting', () => { 160 | const appSettings = { 161 | mute: false, 162 | volume: 5, 163 | fullscreen: false, 164 | fontsize: 15 165 | }; 166 | mockState.setKey('settings', appSettings); 167 | expect(settings.get('mute')).toBe(false); 168 | settings.toggle('mute'); 169 | expect(settings.get('mute')).toBe(true); 170 | settings.toggle('mute'); 171 | expect(settings.get('mute')).toBe(false); 172 | }); 173 | }); 174 | 175 | describe('set', () => { 176 | test('set value - mute (default handler)', () => { 177 | expect(mockSound.mute).not.toHaveBeenCalled(); 178 | settings.set('mute', true); 179 | expect(emit).toHaveBeenCalledWith('settings/set', { name: 'mute', value: true }); 180 | expect(mockSound.mute).toHaveBeenCalledWith(true); 181 | expect(settings.get('mute')).toBe(true); 182 | // toggle 183 | emit.mockClear(); 184 | mockSound.mute.mockClear(); 185 | settings.toggle('mute'); 186 | expect(emit).toHaveBeenCalledWith('settings/set', { name: 'mute', value: false }); 187 | expect(mockSound.mute).toHaveBeenCalledWith(false); 188 | expect(settings.get('mute')).toBe(false); 189 | }); 190 | 191 | test('set value - setVolume (default handler)', () => { 192 | expect(mockSound.setVolume).not.toHaveBeenCalled(); 193 | settings.set('volume', 50); 194 | expect(emit).toHaveBeenCalledWith('settings/set', { name: 'volume', value: 50 }); 195 | expect(mockSound.setVolume).toHaveBeenCalledWith(50); 196 | expect(settings.get('volume')).toBe(50); 197 | // change 198 | emit.mockClear(); 199 | mockSound.setVolume.mockClear(); 200 | settings.set('volume', 90); 201 | expect(emit).toHaveBeenCalledWith('settings/set', { name: 'volume', value: 90 }); 202 | expect(mockSound.setVolume).toHaveBeenCalledWith(90); 203 | expect(settings.get('volume')).toBe(90); 204 | }); 205 | }); 206 | 207 | describe('handlers', () => { 208 | test('custom handler', () => { 209 | const customHandler = jest.fn(); 210 | settings.set('fontsize', 10); 211 | expect(customHandler).not.toHaveBeenCalled(); 212 | settings.defineHandler('fontsize', customHandler); 213 | // set value 214 | settings.set('fontsize', 10); 215 | expect(customHandler).toHaveBeenCalledWith(10, 10); // old and new value 216 | expect(settings.get('fontsize')).toBe(10); 217 | // change 218 | customHandler.mockClear(); 219 | settings.set('fontsize', 30); 220 | expect(customHandler).toHaveBeenCalledWith(10, 30); // old and new value 221 | expect(settings.get('fontsize')).toBe(30); 222 | }); 223 | }); 224 | }); 225 | -------------------------------------------------------------------------------- /__tests__/components/sound.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import { playSound, stopSound, playMusic, playSingleMusic, stopMusic } from '../../src/components/sound'; 3 | import mockState from '../../__mocks__/state'; 4 | 5 | const mockPlaySound = jest.fn(); 6 | const mockStopSound = jest.fn(); 7 | const mockPlayMusic = jest.fn(); 8 | const mockStopMusic = jest.fn(); 9 | 10 | jest.mock('../../src/utils/interfaces', () => ({ 11 | interfaces: jest.fn(() => ({ 12 | state: mockState, 13 | sound: { 14 | playSound: mockPlaySound, 15 | stopSound: mockStopSound, 16 | playMusic: mockPlayMusic, 17 | stopMusic: mockStopMusic 18 | }, 19 | loader: { 20 | getAssetPath: (file) => `/assets/${file}` 21 | } 22 | })) 23 | })); 24 | 25 | beforeEach(() => { 26 | mockState.reset(); 27 | jest.clearAllMocks(); 28 | }); 29 | 30 | 31 | describe('components/sound', () => { 32 | test('playSound', () => { 33 | expect(mockPlaySound).toHaveBeenCalledTimes(0); 34 | playSound('sound/test.mp3'); 35 | expect(mockPlaySound).toHaveBeenCalledTimes(1); 36 | expect(mockPlaySound).toHaveBeenCalledWith('/assets/sound/test.mp3'); 37 | }); 38 | 39 | test('playSound - multiple', () => { 40 | expect(mockPlaySound).toHaveBeenCalledTimes(0); 41 | playSound(['sound/file1.mp3', 'sound/file2.mp3']); 42 | expect(mockPlaySound).toHaveBeenCalledTimes(2); 43 | expect(mockPlaySound).toHaveBeenCalledWith('/assets/sound/file1.mp3'); 44 | expect(mockPlaySound).toHaveBeenCalledWith('/assets/sound/file2.mp3'); 45 | }); 46 | 47 | test('stopSound - specific file', () => { 48 | expect(mockStopSound).toHaveBeenCalledTimes(0); 49 | stopSound('sound/test.mp3'); 50 | expect(mockStopSound).toHaveBeenCalledTimes(1); 51 | expect(mockStopSound).toHaveBeenCalledWith('/assets/sound/test.mp3'); 52 | }); 53 | 54 | test('stopSound - multiple files', () => { 55 | expect(mockStopSound).toHaveBeenCalledTimes(0); 56 | stopSound(['sound/file1.mp3', 'sound/file2.mp3']); 57 | expect(mockStopSound).toHaveBeenCalledTimes(2); 58 | expect(mockStopSound).toHaveBeenCalledWith('/assets/sound/file1.mp3'); 59 | expect(mockStopSound).toHaveBeenCalledWith('/assets/sound/file2.mp3'); 60 | }); 61 | 62 | test('stopSound - all sounds', () => { 63 | expect(mockStopSound).toHaveBeenCalledTimes(0); 64 | stopSound(); 65 | expect(mockStopSound).toHaveBeenCalledTimes(1); 66 | expect(mockStopSound).toHaveBeenCalledWith(null); 67 | }); 68 | 69 | test('playMusic', () => { 70 | expect(mockPlayMusic).toHaveBeenCalledTimes(0); 71 | expect(mockStopMusic).toHaveBeenCalledTimes(0); 72 | playMusic('sound/test.mp3'); 73 | expect(mockStopMusic).toHaveBeenCalledTimes(0); 74 | expect(mockPlayMusic).toHaveBeenCalledTimes(1); 75 | expect(mockPlayMusic).toHaveBeenCalledWith('/assets/sound/test.mp3'); 76 | expect(mockState.get().game.$currentMusic).toEqual(['sound/test.mp3']); 77 | playMusic('sound/test2.mp3'); 78 | expect(mockStopMusic).toHaveBeenCalledTimes(0); 79 | expect(mockPlayMusic).toHaveBeenCalledTimes(2); 80 | expect(mockPlayMusic).toHaveBeenCalledWith('/assets/sound/test2.mp3'); 81 | expect(mockState.get().game.$currentMusic).toEqual(['sound/test.mp3', 'sound/test2.mp3']); 82 | }); 83 | 84 | 85 | test('playSingleMusic', () => { 86 | mockState.setSubkey('game', '$currentMusic', ['sound/m1.mp3', 'sound/m2.mp3']); 87 | expect(mockPlayMusic).toHaveBeenCalledTimes(0); 88 | expect(mockStopMusic).toHaveBeenCalledTimes(0); 89 | 90 | playSingleMusic('sound/test.mp3'); 91 | // stops all music, plays only the music provided 92 | expect(mockStopMusic).toHaveBeenCalledTimes(1); 93 | expect(mockPlayMusic).toHaveBeenCalledTimes(1); 94 | expect(mockPlayMusic).toHaveBeenCalledWith('/assets/sound/test.mp3'); 95 | expect(mockState.get().game.$currentMusic).toEqual(['sound/test.mp3']); 96 | 97 | playSingleMusic(['sound/test2.mp3', 'sound/test3.mp3']); 98 | // stops all music, plays only the last music provided 99 | expect(mockStopMusic).toHaveBeenCalledTimes(2); 100 | expect(mockPlayMusic).toHaveBeenCalledTimes(2); 101 | expect(mockPlayMusic).toHaveBeenCalledWith('/assets/sound/test3.mp3'); 102 | expect(mockState.get().game.$currentMusic).toEqual(['sound/test3.mp3']); 103 | }); 104 | 105 | 106 | test('stopMusic - specific file', () => { 107 | expect(mockStopMusic).toHaveBeenCalledTimes(0); 108 | playMusic(['sound/test.mp3', 'sound/test2.mp3']); 109 | stopMusic('sound/test.mp3'); 110 | expect(mockStopMusic).toHaveBeenCalledTimes(1); 111 | expect(mockStopMusic).toHaveBeenCalledWith('/assets/sound/test.mp3'); 112 | expect(mockState.get().game.$currentMusic).toEqual(['sound/test2.mp3']); 113 | stopMusic('sound/test2.mp3'); 114 | expect(mockStopMusic).toHaveBeenCalledTimes(2); 115 | expect(mockStopMusic).toHaveBeenCalledWith('/assets/sound/test2.mp3'); 116 | expect(mockState.get().game.$currentMusic).toEqual([]); 117 | }); 118 | 119 | test('stopMusic - multiple files', () => { 120 | expect(mockStopMusic).toHaveBeenCalledTimes(0); 121 | playMusic(['sound/test.mp3', 'sound/test2.mp3']); 122 | stopMusic(['sound/test.mp3', 'sound/test2.mp3']); 123 | expect(mockStopMusic).toHaveBeenCalledTimes(2); 124 | expect(mockStopMusic).toHaveBeenCalledWith('/assets/sound/test.mp3'); 125 | expect(mockStopMusic).toHaveBeenCalledWith('/assets/sound/test2.mp3'); 126 | expect(mockState.get().game.$currentMusic).toEqual([]); 127 | }); 128 | 129 | test('stopMusic without current music', () => { 130 | expect(mockStopMusic).toHaveBeenCalledTimes(0); 131 | stopMusic(['sound/test.mp3', 'sound/test2.mp3']); 132 | expect(mockStopMusic).toHaveBeenCalledTimes(2); 133 | expect(mockStopMusic).toHaveBeenCalledWith('/assets/sound/test.mp3'); 134 | expect(mockStopMusic).toHaveBeenCalledWith('/assets/sound/test2.mp3'); 135 | expect(mockState.get().game.$currentMusic).toEqual([]); 136 | }); 137 | 138 | test('stopMusic - all music', () => { 139 | expect(mockStopMusic).toHaveBeenCalledTimes(0); 140 | playMusic(['sound/test.mp3', 'sound/test2.mp3']); 141 | stopMusic(); 142 | expect(mockStopMusic).toHaveBeenCalledTimes(1); 143 | expect(mockStopMusic).toHaveBeenCalledWith(); 144 | expect(mockState.get().game.$currentMusic).toEqual([]); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import atrament from '../src/index'; 3 | 4 | import packageInfo from '../package.json'; 5 | import { getConfig, setConfig } from '../src/utils/config'; 6 | import { emitter, emit } from '../src/utils/emitter'; 7 | import settings from '../src/components/settings'; 8 | 9 | const filePath = '/path/to'; 10 | 11 | const mockGetState = jest.fn(() => ({ 12 | game: { 13 | $path: filePath 14 | } 15 | })); 16 | 17 | const mockStateStore = 'STORE'; 18 | 19 | const mockState = { 20 | get: mockGetState, 21 | store: jest.fn(() => mockStateStore) 22 | }; 23 | 24 | const mockPersistent = { 25 | init: jest.fn() 26 | }; 27 | 28 | const mockInterfaces = { 29 | state: mockState, 30 | persistent: mockPersistent 31 | }; 32 | 33 | jest.mock('../src/utils/interfaces', () => ({ 34 | interfaces: jest.fn(() => mockInterfaces) 35 | })); 36 | 37 | jest.mock('../src/components/settings', () => ({ 38 | load: jest.fn(async () => 'LOAD') 39 | })); 40 | 41 | jest.mock('../src/utils/emitter', () => ({ 42 | emit: jest.fn(), 43 | emitter: { 44 | on: jest.fn(), 45 | off: jest.fn() 46 | } 47 | })); 48 | 49 | const Story = () => ({ inkStoryConstructor: true }); 50 | 51 | beforeEach(() => { 52 | jest.clearAllMocks(); 53 | setConfig(Story, { applicationID: 'testAppID' }); 54 | }); 55 | 56 | describe('atrament', () => { 57 | test('returns valid version', () => { 58 | const atramentVersion = atrament.version; 59 | expect(atramentVersion).toEqual(packageInfo.version); 60 | }); 61 | 62 | test('returns interfaces', () => { 63 | const atramentInterfaces = atrament.interfaces; 64 | expect(atramentInterfaces).toEqual(mockInterfaces); 65 | }); 66 | 67 | test('returns state', () => { 68 | const atramentState = atrament.state; 69 | expect(atramentState).toEqual(mockState); 70 | }); 71 | 72 | test('returns store', () => { 73 | const atramentState = atrament.store; 74 | expect(atramentState).toEqual(mockStateStore); 75 | }); 76 | 77 | test('subscribe/unsubscribe', () => { 78 | const callback = () => 'callback'; 79 | expect(emitter.on).not.toHaveBeenCalled(); 80 | atrament.on('event', callback); 81 | expect(emitter.on).toHaveBeenCalledWith('event', callback); 82 | expect(emitter.off).not.toHaveBeenCalled(); 83 | atrament.off('event', callback); 84 | expect(emitter.off).toHaveBeenCalledWith('event', callback); 85 | }); 86 | 87 | test('init', async () => { 88 | expect(emit).not.toHaveBeenCalled(); 89 | expect(settings.load).not.toHaveBeenCalled(); 90 | expect(mockPersistent.init).not.toHaveBeenCalled(); 91 | expect(getConfig()).toEqual({ 92 | InkStory: Story, 93 | applicationID: 'testAppID', 94 | settings: { mute: true, volume: 0 } 95 | }); 96 | // run 97 | const cfg = { applicationID: 'TEST APP' }; 98 | await atrament.init(Story, cfg); 99 | // check 100 | expect(getConfig()).toEqual({ 101 | InkStory: Story, 102 | applicationID: cfg.applicationID, 103 | settings: { mute: true, volume: 0 } 104 | }); 105 | expect(emit).toHaveBeenCalledWith('atrament/init'); 106 | expect(mockPersistent.init).toHaveBeenCalledWith(cfg.applicationID); 107 | expect(settings.load).toHaveBeenCalledTimes(1); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /__tests__/utils/config.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import { getConfig, setConfig } from '../../src/utils/config'; 3 | 4 | const defaultConfig = { 5 | settings: { 6 | volume: 0, 7 | mute: true 8 | } 9 | }; 10 | 11 | const Story = () => ({ inkStoryConstructor: true }); 12 | 13 | afterAll(() => setConfig(Story, { ...defaultConfig, applicationID: 'jest-test' })); 14 | 15 | describe('utils/config', () => { 16 | test('no params', () => { 17 | expect(() => { 18 | setConfig(); 19 | }).toThrow('atrament.init: provide ink Story constructor as a first argument!'); 20 | }); 21 | 22 | test('returns valid config', () => { 23 | const cfg = getConfig(); 24 | expect(cfg).toEqual(defaultConfig); 25 | }); 26 | 27 | test('no valid Story constructor provided', () => { 28 | expect(() => { 29 | setConfig({ some: 'config' }); 30 | }).toThrow('atrament.init: Story is not a constructor!'); 31 | }); 32 | 33 | test('no config provided', () => { 34 | expect(() => setConfig(Story)).toThrow('atrament.init: config.applicationID is not set!'); 35 | }); 36 | 37 | test('config without applicationID', () => { 38 | expect( 39 | () => setConfig(Story, { settings: { mute: true } }) 40 | ).toThrow('atrament.init: config.applicationID is not set!'); 41 | }); 42 | 43 | test('sets config', () => { 44 | setConfig(Story, { 45 | applicationID: 'jest-test', 46 | settings: { 47 | fullscreen: true 48 | } 49 | }); 50 | const cfg = getConfig(); 51 | expect(cfg).toEqual({ 52 | applicationID: 'jest-test', 53 | InkStory: Story, 54 | settings: { 55 | fullscreen: true 56 | } 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /__tests__/utils/emitter.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import { emitter, emit } from '../../src/utils/emitter'; 3 | 4 | describe('utils/emitter', () => { 5 | test('subscribe, emit, unsubscribe', () => { 6 | const eventHandler = jest.fn(); 7 | 8 | emit('event', 'eventParameter'); 9 | expect(eventHandler).toBeCalledTimes(0); 10 | 11 | emitter.on('event', eventHandler); 12 | emit('event', 'eventParameter'); 13 | expect(eventHandler).toBeCalledTimes(1); 14 | expect(eventHandler).toBeCalledWith('eventParameter'); 15 | eventHandler.mockClear(); 16 | 17 | emit('event', { key: 'value' }); 18 | expect(eventHandler).toBeCalledTimes(1); 19 | expect(eventHandler).toBeCalledWith({ key: 'value' }); 20 | eventHandler.mockClear(); 21 | 22 | emitter.off('event', eventHandler); 23 | emit('event', 'eventParameter'); 24 | expect(eventHandler).toBeCalledTimes(0); 25 | eventHandler.mockClear(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /__tests__/utils/hashcode.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import hashCode from '../../src/utils/hashcode'; 3 | 4 | describe('utils/hashcode', () => { 5 | test('returns valid hash', () => { 6 | const hashed = hashCode('test/string'); 7 | expect(hashed).toBe(108729390); 8 | const hashed2 = hashCode('test/anotherstring'); 9 | expect(hashed2).not.toBe(hashed); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /__tests__/utils/interfaces.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import { interfaces, defineInterfaces } from '../../src/utils/interfaces'; 3 | 4 | const defaultInterfaces = { 5 | loader: null, 6 | persistent: null, 7 | sound: { 8 | init: expect.any(Function), 9 | mute: expect.any(Function), 10 | isMuted: expect.any(Function), 11 | setVolume: expect.any(Function), 12 | getVolume: expect.any(Function), 13 | playSound: expect.any(Function), 14 | playMusic: expect.any(Function), 15 | stopMusic: expect.any(Function) 16 | }, 17 | state: null 18 | }; 19 | 20 | afterEach(() => defineInterfaces(defaultInterfaces)); 21 | 22 | describe('utils/interfaces', () => { 23 | test('define some interfaces', () => { 24 | let i = interfaces(); 25 | expect(i).toEqual(defaultInterfaces); 26 | defineInterfaces({ 27 | loader: 'loaderInterface', 28 | persistent: 'persistentInterface' 29 | }); 30 | i = interfaces(); 31 | expect(i).toEqual({ 32 | loader: 'loaderInterface', 33 | persistent: 'persistentInterface', 34 | sound: defaultInterfaces.sound, 35 | state: null 36 | }); 37 | }); 38 | 39 | test('define unknown interfaces', () => { 40 | let i = interfaces(); 41 | expect(i).toEqual(defaultInterfaces); 42 | defineInterfaces({ 43 | unknown: 'unknownInterface', 44 | persistent: 'persistentInterface' 45 | }); 46 | i = interfaces(); 47 | expect(i).toEqual({ 48 | loader: null, 49 | persistent: 'persistentInterface', 50 | sound: defaultInterfaces.sound, 51 | state: null, 52 | unknown: 'unknownInterface' 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /__tests__/utils/scene-processors.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import internalSceneProcessors from '../../src/utils/scene-processors'; 3 | 4 | 5 | describe('utils/scene-processors', () => { 6 | describe('#IMAGE tags', () => { 7 | test('single image', () => { 8 | const scene = { 9 | content: [{ 10 | text: 'aaa', 11 | tags: { IMAGE: 'img.jpg' } 12 | }], 13 | text: ['aaaa'], 14 | tags: {}, 15 | choices: [] 16 | }; 17 | internalSceneProcessors.forEach((p) => p(scene)); 18 | expect(scene).toEqual({ 19 | content: [{ 20 | text: 'aaa', 21 | tags: { IMAGE: 'img.jpg' }, 22 | images: ['img.jpg'], 23 | sounds: [], 24 | music: [] 25 | }], 26 | text: ['aaaa'], 27 | tags: {}, 28 | choices: [], 29 | images: ['img.jpg'], 30 | sounds: [], 31 | music: [] 32 | }); 33 | }); 34 | 35 | test('multiple images', () => { 36 | const scene = { 37 | content: [{ 38 | text: 'aaa', 39 | tags: { IMAGE: ['img.jpg', 'img2.jpg'] } 40 | }], 41 | text: ['aaaa'], 42 | tags: {}, 43 | choices: [] 44 | }; 45 | internalSceneProcessors.forEach((p) => p(scene)); 46 | expect(scene).toEqual({ 47 | content: [{ 48 | text: 'aaa', 49 | tags: { IMAGE: ['img.jpg', 'img2.jpg'] }, 50 | images: ['img.jpg', 'img2.jpg'], 51 | sounds: [], 52 | music: [] 53 | }], 54 | text: ['aaaa'], 55 | tags: {}, 56 | choices: [], 57 | images: ['img.jpg', 'img2.jpg'], 58 | sounds: [], 59 | music: [] 60 | }); 61 | }); 62 | 63 | test('multiple images in different paragraphs', () => { 64 | const scene = { 65 | content: [{ 66 | text: 'aaa', 67 | tags: { IMAGE: ['img.jpg', 'img2.jpg'] } 68 | }, { 69 | text: 'bbb', 70 | tags: { IMAGE: 'img3.jpg' } 71 | }], 72 | text: ['aaaa'], 73 | tags: {}, 74 | choices: [] 75 | }; 76 | internalSceneProcessors.forEach((p) => p(scene)); 77 | expect(scene).toEqual({ 78 | content: [{ 79 | text: 'aaa', 80 | tags: { IMAGE: ['img.jpg', 'img2.jpg'] }, 81 | images: ['img.jpg', 'img2.jpg'], 82 | sounds: [], 83 | music: [] 84 | }, { 85 | text: 'bbb', 86 | tags: { IMAGE: 'img3.jpg' }, 87 | images: ['img3.jpg'], 88 | sounds: [], 89 | music: [] 90 | }], 91 | text: ['aaaa'], 92 | tags: {}, 93 | choices: [], 94 | images: ['img.jpg', 'img2.jpg', 'img3.jpg'], 95 | sounds: [], 96 | music: [] 97 | }); 98 | }); 99 | }); 100 | 101 | describe('sound and music tags', () => { 102 | test('single sound', () => { 103 | const scene = { 104 | content: [{ 105 | text: 'aaa', 106 | tags: { AUDIO: 'sound.mp3', AUDIOLOOP: 'music.mp3' } 107 | }], 108 | text: ['aaaa'], 109 | tags: {}, 110 | choices: [] 111 | }; 112 | internalSceneProcessors.forEach((p) => p(scene)); 113 | expect(scene).toEqual({ 114 | content: [{ 115 | text: 'aaa', 116 | tags: { AUDIO: 'sound.mp3', AUDIOLOOP: 'music.mp3' }, 117 | images: [], 118 | sounds: ['sound.mp3'], 119 | music: ['music.mp3'] 120 | }], 121 | text: ['aaaa'], 122 | tags: {}, 123 | choices: [], 124 | images: [], 125 | sounds: ['sound.mp3'], 126 | music: ['music.mp3'] 127 | }); 128 | }); 129 | 130 | test('multiple sounds and music', () => { 131 | const scene = { 132 | content: [{ 133 | text: 'aaa', 134 | tags: { 135 | PLAY_SOUND: ['sound1.mp3', 'sound2.mp3'], 136 | PLAY_MUSIC: ['music1.mp3', 'music2.mp3'] 137 | } 138 | }], 139 | text: ['aaaa'], 140 | tags: {}, 141 | choices: [] 142 | }; 143 | internalSceneProcessors.forEach((p) => p(scene)); 144 | expect(scene).toEqual({ 145 | content: [{ 146 | text: 'aaa', 147 | tags: { 148 | PLAY_SOUND: ['sound1.mp3', 'sound2.mp3'], 149 | PLAY_MUSIC: ['music1.mp3', 'music2.mp3'] 150 | }, 151 | images: [], 152 | sounds: ['sound1.mp3', 'sound2.mp3'], 153 | music: ['music1.mp3', 'music2.mp3'] 154 | }], 155 | text: ['aaaa'], 156 | tags: {}, 157 | choices: [], 158 | images: [], 159 | sounds: ['sound1.mp3', 'sound2.mp3'], 160 | music: ['music1.mp3', 'music2.mp3'] 161 | }); 162 | }); 163 | 164 | test('multiple images and music in different paragraphs', () => { 165 | const scene = { 166 | content: [{ 167 | text: 'aaa', 168 | tags: { 169 | AUDIO: ['sound1.mp3', 'sound2.mp3'], 170 | AUDIOLOOP: 'music1.mp3' 171 | } 172 | }, { 173 | text: 'bbb', 174 | tags: { 175 | AUDIO: 'audio.mp3', 176 | PLAY_SOUND: 'sound3.mp3', 177 | PLAY_MUSIC: ['music2.mp3', 'music3.mp3'] 178 | } 179 | }], 180 | text: ['aaaa'], 181 | tags: {}, 182 | choices: [] 183 | }; 184 | internalSceneProcessors.forEach((p) => p(scene)); 185 | expect(scene).toEqual({ 186 | content: [{ 187 | text: 'aaa', 188 | tags: { 189 | AUDIO: ['sound1.mp3', 'sound2.mp3'], 190 | AUDIOLOOP: 'music1.mp3' 191 | }, 192 | images: [], 193 | sounds: ['sound1.mp3', 'sound2.mp3'], 194 | music: ['music1.mp3'] 195 | }, { 196 | text: 'bbb', 197 | tags: { 198 | AUDIO: 'audio.mp3', 199 | PLAY_SOUND: 'sound3.mp3', 200 | PLAY_MUSIC: ['music2.mp3', 'music3.mp3'] 201 | }, 202 | images: [], 203 | sounds: ['audio.mp3', 'sound3.mp3'], 204 | music: ['music2.mp3', 'music3.mp3'] 205 | }], 206 | text: ['aaaa'], 207 | tags: {}, 208 | choices: [], 209 | images: [], 210 | sounds: ['sound1.mp3', 'sound2.mp3', 'audio.mp3', 'sound3.mp3'], 211 | music: ['music1.mp3', 'music2.mp3', 'music3.mp3'] 212 | }); 213 | }); 214 | }); 215 | 216 | describe('disabled choices', () => { 217 | function testChoiceTag(choiceTag) { 218 | const scene = { 219 | content: [{ 220 | text: 'aaa', 221 | tags: {} 222 | }], 223 | text: ['aaaa'], 224 | tags: {}, 225 | choices: [{ 226 | id: 1, 227 | choice: 'ccc', 228 | tags: { [choiceTag]: true } 229 | }, 230 | { 231 | id: 2, 232 | choice: 'ddd', 233 | tags: {} 234 | }] 235 | }; 236 | internalSceneProcessors.forEach((p) => p(scene)); 237 | expect(scene).toEqual({ 238 | content: [{ 239 | text: 'aaa', 240 | tags: {}, 241 | images: [], 242 | sounds: [], 243 | music: [] 244 | }], 245 | text: ['aaaa'], 246 | tags: {}, 247 | choices: [{ 248 | id: 1, 249 | choice: 'ccc', 250 | tags: { [choiceTag]: true }, 251 | disabled: true 252 | }, 253 | { 254 | id: 2, 255 | choice: 'ddd', 256 | tags: {} 257 | }], 258 | images: [], 259 | sounds: [], 260 | music: [] 261 | }); 262 | } 263 | 264 | test('UNCLICKABLE', () => { 265 | testChoiceTag('UNCLICKABLE'); 266 | }); 267 | 268 | test('DISABLED', () => { 269 | testChoiceTag('DISABLED'); 270 | }); 271 | 272 | test('INACTIVE', () => { 273 | testChoiceTag('INACTIVE'); 274 | }); 275 | }); 276 | }); 277 | -------------------------------------------------------------------------------- /__tests__/utils/tags.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import mockConsole from 'jest-mock-console'; /* eslint-disable-line */ 3 | import { parseTags } from '../../src/utils/tags'; 4 | 5 | describe('utils/tags', () => { 6 | test('no tags', () => { 7 | const tags = parseTags(); 8 | expect(tags).toEqual({}); 9 | }); 10 | 11 | test('simple tags', () => { 12 | const tags = parseTags(['HELLO', 'WORLD']); 13 | expect(tags).toEqual({ 14 | HELLO: true, 15 | WORLD: true 16 | }); 17 | }); 18 | 19 | test('parameterized tags', () => { 20 | const tags = parseTags(['TAG1: named', 'TAG2: config:string']); 21 | expect(tags).toEqual({ 22 | TAG1: 'named', 23 | TAG2: 'config:string' 24 | }); 25 | }); 26 | 27 | test('parameterized tags - boolean', () => { 28 | const tags = parseTags(['TAG1: true', 'TAG2: false']); 29 | expect(tags).toEqual({ 30 | TAG1: true, 31 | TAG2: false 32 | }); 33 | }); 34 | 35 | test('parameterized tags - strings', () => { 36 | const tags = parseTags(['TAG1: "true"', 'TAG2: "false"']); 37 | expect(tags).toEqual({ 38 | TAG1: 'true', 39 | TAG2: 'false' 40 | }); 41 | }); 42 | 43 | test('JSON tags', () => { 44 | const tags = parseTags(['JSONOBJECT: {"key":"value"}', 'JSONARRAY: ["hello", "world"]']); 45 | expect(tags).toEqual({ 46 | JSONOBJECT: { key: 'value' }, 47 | JSONARRAY: ['hello', 'world'] 48 | }); 49 | }); 50 | 51 | test('JSON tags - ignore invalid JSON', () => { 52 | const restoreConsole = mockConsole(); 53 | const tags = parseTags(['JSONOBJECT: {"key":}', 'JSONARRAY: ["hello", "world"', 'VALIDJSON: {"key":"value"}']); 54 | expect(tags).toEqual({ 55 | VALIDJSON: { key: 'value' } 56 | }); 57 | restoreConsole(); 58 | }); 59 | 60 | test('multiple tags', () => { 61 | const tags = parseTags(['title: Game Title', 'observe: var1', 'observe: var2', 'observe: var3']); 62 | expect(tags).toEqual({ 63 | title: 'Game Title', 64 | observe: ['var1', 'var2', 'var3'] 65 | }); 66 | }); 67 | 68 | test('combined', () => { 69 | const restoreConsole = mockConsole(); 70 | const tags = parseTags([ 71 | 'HELLO', 72 | 'BOOLEANTAG: false', 73 | 'MALFORMEDJSONARRAY: ["hello", "world"', 74 | 'title: Game Title', 75 | 'JSONARRAY: ["hello", "world"]', 76 | 'observe: var1', 77 | 'MALFORMEDJSONOBJECT: {"key":}', 78 | 'WORLD', 79 | 'observe: var2', 80 | 'JSONOBJECT: {"key":"value"}', 81 | 'observe: var3' 82 | ]); 83 | expect(tags).toEqual({ 84 | HELLO: true, 85 | WORLD: true, 86 | BOOLEANTAG: false, 87 | JSONOBJECT: { key: 'value' }, 88 | JSONARRAY: ['hello', 'world'], 89 | title: 'Game Title', 90 | observe: ['var1', 'var2', 'var3'] 91 | }); 92 | restoreConsole(); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = (api) => { 2 | // api.cache(true); 3 | const isTest = api.env('test'); 4 | const presetEnv = { 5 | targets: { 6 | browsers: ['>0.5%', 'not ie 11', 'not op_mini all'] 7 | } 8 | }; 9 | if (isTest) { 10 | // fix issue with Story constructor in class AtramentStory 11 | // presetEnv.exclude = ['@babel/plugin-transform-classes']; 12 | presetEnv.targets = { node: 'current' }; 13 | } 14 | return { 15 | presets: [ 16 | ['@babel/preset-env', presetEnv] 17 | ] 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /* eslint max-len: "off" */ 2 | // For a detailed explanation regarding each configuration property, visit: 3 | // https://jestjs.io/docs/en/configuration.html 4 | 5 | module.exports = { 6 | // All imported modules in your tests should be mocked automatically 7 | // automock: false, 8 | 9 | // Stop running tests after `n` failures 10 | // bail: 0, 11 | 12 | // Respect "browser" field in package.json when resolving modules 13 | // browser: false, 14 | 15 | // The directory where Jest should store its cached dependency information 16 | // cacheDirectory: "C:\\Users\\techniX\\AppData\\Local\\Temp\\jest", 17 | 18 | // Automatically clear mock calls and instances between every test 19 | clearMocks: true, 20 | 21 | // Indicates whether the coverage information should be collected while executing the test 22 | collectCoverage: true, 23 | 24 | // An array of glob patterns indicating a set of files for which coverage information should be collected 25 | collectCoverageFrom: ['src/**'], 26 | 27 | // The directory where Jest should output its coverage files 28 | coverageDirectory: 'coverage', 29 | 30 | // An array of regexp pattern strings used to skip coverage collection 31 | // coveragePathIgnorePatterns: [ 32 | // "\\\\node_modules\\\\" 33 | // ], 34 | 35 | // A list of reporter names that Jest uses when writing coverage reports 36 | // coverageReporters: [ 37 | // "json", 38 | // "text", 39 | // "lcov", 40 | // "clover" 41 | // ], 42 | 43 | // An object that configures minimum threshold enforcement for coverage results 44 | // coverageThreshold: null, 45 | 46 | // A path to a custom dependency extractor 47 | // dependencyExtractor: null, 48 | 49 | // Make calling deprecated APIs throw helpful error messages 50 | // errorOnDeprecated: false, 51 | 52 | // Force coverage collection from ignored files using an array of glob patterns 53 | // forceCoverageMatch: [], 54 | 55 | // A path to a module which exports an async function that is triggered once before all test suites 56 | // globalSetup: null, 57 | 58 | // A path to a module which exports an async function that is triggered once after all test suites 59 | // globalTeardown: null, 60 | 61 | // A set of global variables that need to be available in all test environments 62 | // globals: {}, 63 | 64 | // An array of directory names to be searched recursively up from the requiring module's location 65 | // moduleDirectories: [ 66 | // "node_modules" 67 | // ], 68 | 69 | // An array of file extensions your modules use 70 | // moduleFileExtensions: [ 71 | // "js", 72 | // "json", 73 | // "jsx", 74 | // "ts", 75 | // "tsx", 76 | // "node" 77 | // ], 78 | 79 | // A map from regular expressions to module names that allow to stub out resources with a single module 80 | // moduleNameMapper: {}, 81 | 82 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 83 | // modulePathIgnorePatterns: [], 84 | 85 | // Activates notifications for test results 86 | // notify: false, 87 | 88 | // An enum that specifies notification mode. Requires { notify: true } 89 | // notifyMode: "failure-change", 90 | 91 | // A preset that is used as a base for Jest's configuration 92 | // preset: null, 93 | 94 | // Run tests from one or more projects 95 | // projects: null, 96 | 97 | // Use this configuration option to add custom reporters to Jest 98 | // reporters: undefined, 99 | 100 | // Automatically reset mock state between every test 101 | // resetMocks: false, 102 | 103 | // Reset the module registry before running each individual test 104 | // resetModules: false, 105 | 106 | // A path to a custom resolver 107 | // resolver: null, 108 | 109 | // Automatically restore mock state between every test 110 | // restoreMocks: false, 111 | 112 | // The root directory that Jest should scan for tests and modules within 113 | // rootDir: null, 114 | 115 | // A list of paths to directories that Jest should use to search for files in 116 | // roots: [ 117 | // '/src/', 118 | // '/__tests__/' 119 | // ], 120 | 121 | // Allows you to use a custom runner instead of Jest's default test runner 122 | // runner: "jest-runner", 123 | 124 | // The paths to modules that run some code to configure or set up the testing environment before each test 125 | // setupFiles: [], 126 | 127 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 128 | // setupFilesAfterEnv: [], 129 | 130 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 131 | // snapshotSerializers: [], 132 | 133 | // The test environment that will be used for testing 134 | testEnvironment: 'node', 135 | 136 | // Options that will be passed to the testEnvironment 137 | // testEnvironmentOptions: {}, 138 | 139 | // Adds a location field to test results 140 | // testLocationInResults: false, 141 | 142 | // The glob patterns Jest uses to detect test files 143 | // testMatch: [ 144 | // "**/__tests__/**/*.[jt]s?(x)", 145 | // "**/?(*.)+(spec|test).[tj]s?(x)" 146 | // ], 147 | 148 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 149 | // testPathIgnorePatterns: [ 150 | // "\\\\node_modules\\\\" 151 | // ], 152 | 153 | // The regexp pattern or array of patterns that Jest uses to detect test files 154 | // testRegex: [], 155 | 156 | // This option allows the use of a custom results processor 157 | // testResultsProcessor: null, 158 | 159 | // This option allows use of a custom test runner 160 | // testRunner: "jasmine2", 161 | 162 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 163 | // testURL: "http://localhost", 164 | 165 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 166 | // timers: "real", 167 | 168 | // A map from regular expressions to paths to transformers 169 | // transform: null, 170 | 171 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 172 | transformIgnorePatterns: [ 173 | 'node_modules/(?!(nanostores|@nanostores)/)' 174 | ] 175 | 176 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 177 | // unmockedModulePathPatterns: undefined, 178 | 179 | // Indicates whether each individual test should be reported during the run 180 | // verbose: null, 181 | 182 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 183 | // watchPathIgnorePatterns: [], 184 | 185 | // Whether to use watchman for file crawling 186 | // watchman: true, 187 | }; 188 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@atrament/core", 3 | "version": "2.0.0", 4 | "description": "Atrament, a text game engine - core module", 5 | "main": "src/index.js", 6 | "files": [ 7 | "src" 8 | ], 9 | "scripts": { 10 | "test": "jest", 11 | "lint": "eslint . --ext .js" 12 | }, 13 | "author": "Serhii Mozhaiskyi (sergei.mozhaisky@gmail.com)", 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/technix/atrament-core.git" 17 | }, 18 | "license": "MIT", 19 | "dependencies": { 20 | "mitt": "3.0.1" 21 | }, 22 | "devDependencies": { 23 | "@babel/core": "7.25.2", 24 | "@babel/eslint-parser": "7.25.1", 25 | "@babel/preset-env": "7.25.4", 26 | "@babel/register": "7.24.6", 27 | "eslint": "8.49.0", 28 | "eslint-config-airbnb-base": "15.0.0", 29 | "eslint-plugin-import": "2.29.1", 30 | "jest": "29.7.0", 31 | "jest-mock-console": "2.0.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/components/game/control.js: -------------------------------------------------------------------------------- 1 | import { interfaces } from '../../utils/interfaces'; 2 | import { emit } from '../../utils/emitter'; 3 | 4 | import hashCode from '../../utils/hashcode'; 5 | import toArray from '../../utils/to-array'; 6 | 7 | import ink from '../ink'; 8 | import { persistentPrefix, load, existSave } from '../saves'; 9 | import { playMusic, stopMusic } from '../sound'; 10 | 11 | 12 | let expectedInkScriptUUID = null; 13 | let currentInkScriptUUID = null; 14 | 15 | 16 | function $clearGameState() { 17 | const { state } = interfaces(); 18 | // reset state 19 | state.setKey('scenes', []); 20 | state.setKey('vars', {}); 21 | } 22 | 23 | 24 | function $iterateObservers(observerHandler) { 25 | const { state } = interfaces(); 26 | const observers = state.get().metadata.observe; 27 | if (observers) { 28 | toArray(observers).forEach(observerHandler); 29 | } 30 | } 31 | 32 | 33 | const persistentVarState = {}; 34 | 35 | async function $handlePersistent() { 36 | const { state, persistent } = interfaces(); 37 | const { game, metadata } = state.get(); 38 | const persistentVars = metadata.persist; 39 | if (persistentVars) { 40 | const storeID = persistentPrefix('persist'); 41 | // load persistent data, if possible 42 | if (await persistent.exists(storeID)) { 43 | persistentVarState[game.$gameUUID] = await persistent.get(storeID); 44 | Object.entries(persistentVarState[game.$gameUUID]).forEach( 45 | ([k, v]) => ink.setVariable(k, v) 46 | ); 47 | } else if (!persistentVarState[game.$gameUUID]) { 48 | persistentVarState[game.$gameUUID] = {}; 49 | } 50 | // register observers for persistent vars 51 | toArray(persistentVars).forEach((variable) => { 52 | ink.observeVariable(variable, async (name, value) => { 53 | persistentVarState[game.$gameUUID][name] = value; 54 | await persistent.set(storeID, persistentVarState[game.$gameUUID]); 55 | }); 56 | }); 57 | } 58 | } 59 | 60 | // =========================================================== 61 | 62 | export async function init(pathToInkFile, inkFile, gameID) { 63 | await interfaces().loader.init(pathToInkFile); 64 | const gameObj = { 65 | $path: pathToInkFile, 66 | $file: inkFile, 67 | $gameUUID: gameID || hashCode(`${pathToInkFile}|${inkFile}`) 68 | }; 69 | interfaces().state.setKey('game', gameObj); 70 | expectedInkScriptUUID = gameObj.$gameUUID; // expecting to load content with this UUID 71 | emit('game/init', { pathToInkFile, inkFile }); 72 | } 73 | 74 | 75 | export async function loadInkFile() { 76 | const { game } = interfaces().state.get(); 77 | let inkContent = await interfaces().loader.loadInk(game.$file); 78 | if (typeof inkContent === 'string') { 79 | inkContent = JSON.parse(inkContent.replace('\uFEFF', '')); 80 | } 81 | emit('game/loadInkFile', game.$file); 82 | return inkContent; 83 | } 84 | 85 | 86 | export async function initInkStory() { 87 | const { state } = interfaces(); 88 | const inkContent = await loadInkFile(); 89 | // initialize InkJS 90 | ink.initStory(inkContent); 91 | // read global tags 92 | const metadata = ink.getGlobalTags(); 93 | state.setKey('metadata', metadata); 94 | // register variable observers 95 | $iterateObservers((variable) => { 96 | ink.observeVariable(variable, (name, value) => { 97 | state.setSubkey('vars', name, value); 98 | emit('ink/variableObserver', { name, value }); 99 | }); 100 | }); 101 | currentInkScriptUUID = expectedInkScriptUUID; 102 | emit('game/initInkStory'); 103 | } 104 | 105 | 106 | export async function start(saveSlot) { 107 | const { state } = interfaces(); 108 | stopMusic(); // stop all music 109 | $clearGameState(); // game state cleanup 110 | if (currentInkScriptUUID !== expectedInkScriptUUID) { 111 | // ink content is not from the same game, reload 112 | await initInkStory(); 113 | } 114 | // load saved game, if present 115 | if (saveSlot) { 116 | if (await existSave(saveSlot)) { 117 | await load(saveSlot); 118 | const { game } = state.get(); 119 | // restore music 120 | if (game.$currentMusic) { 121 | playMusic(game.$currentMusic); 122 | } 123 | } 124 | } 125 | await $handlePersistent(); 126 | // read initial state of observed variables 127 | $iterateObservers((variable) => { 128 | state.setSubkey('vars', variable, ink.getVariable(variable)); 129 | }); 130 | emit('game/start', { saveSlot }); 131 | } 132 | 133 | 134 | export function clear() { 135 | stopMusic(); // stop all music 136 | $clearGameState(); 137 | ink.resetStory(); // reset ink story state 138 | emit('game/clear'); 139 | } 140 | 141 | export function reset() { 142 | const { state } = interfaces(); 143 | clear(); 144 | // clean metadata and game 145 | state.setKey('metadata', {}); 146 | state.setKey('game', {}); 147 | emit('game/reset'); 148 | } 149 | -------------------------------------------------------------------------------- /src/components/game/index.js: -------------------------------------------------------------------------------- 1 | import { interfaces } from '../../utils/interfaces'; 2 | import { emit } from '../../utils/emitter'; 3 | 4 | import { init, loadInkFile, initInkStory, start, clear, reset } from './control'; 5 | import { getSession, setSession, getSessions, removeSession } from './sessions'; 6 | 7 | import ink from '../ink'; 8 | import { playSound, stopSound, playMusic, playSingleMusic, stopMusic } from '../sound'; 9 | import { 10 | getSaveSlotKey, 11 | load, 12 | save, 13 | existSave, 14 | removeSave, 15 | listSaves, 16 | SAVE_GAME, 17 | SAVE_AUTOSAVE, 18 | SAVE_CHECKPOINT 19 | } from '../saves'; 20 | 21 | import internalSceneProcessors from '../../utils/scene-processors'; 22 | 23 | const sceneProcessors = []; 24 | 25 | // =========================================== 26 | 27 | async function canResume() { 28 | let saveSlot = null; 29 | const autosaveSlot = getSaveSlotKey({ type: SAVE_AUTOSAVE }); 30 | if (await existSave(autosaveSlot)) { 31 | saveSlot = autosaveSlot; 32 | } else { 33 | const saves = await listSaves(); 34 | const checkpoints = saves.filter((k) => k.type === SAVE_CHECKPOINT); 35 | if (checkpoints.length) { 36 | saveSlot = checkpoints.sort((a, b) => b.date - a.date)[0].id; 37 | } 38 | } 39 | emit('game/canResume', saveSlot); 40 | return saveSlot; 41 | } 42 | 43 | 44 | async function resume() { 45 | const saveSlot = await canResume(); 46 | emit('game/resume', { saveSlot }); 47 | ink.resetStory(); // reset ink story state 48 | await start(saveSlot); 49 | } 50 | 51 | 52 | async function restart(saveSlot) { 53 | emit('game/restart', { saveSlot }); 54 | ink.resetStory(); // reset ink story state 55 | await start(saveSlot); 56 | } 57 | 58 | 59 | async function restartAndContinue(saveSlot) { 60 | await restart(saveSlot); 61 | continueStory(); 62 | } 63 | 64 | 65 | async function saveGame(name) { 66 | await save({ type: SAVE_GAME, name }); 67 | } 68 | 69 | 70 | async function saveCheckpoint(name) { 71 | await save({ type: SAVE_CHECKPOINT, name }); 72 | } 73 | 74 | 75 | async function saveAutosave() { 76 | await save({ type: SAVE_AUTOSAVE }); 77 | } 78 | 79 | 80 | const tagHandlers = { 81 | CLEAR: () => interfaces().state.setKey('scenes', []), 82 | AUDIO: (v) => (v ? playSound(v) : stopSound()), 83 | AUDIOLOOP: (v) => (v ? playSingleMusic(v) : stopMusic()), 84 | PLAY_SOUND: playSound, 85 | STOP_SOUND: (v) => (v === true ? stopSound() : stopSound(v)), 86 | PLAY_MUSIC: playMusic, 87 | STOP_MUSIC: (v) => (v === true ? stopMusic() : stopMusic(v)), 88 | CHECKPOINT: (v) => saveCheckpoint(v), 89 | SAVEGAME: (v) => saveGame(v) 90 | }; 91 | 92 | 93 | function $processTags(list, tags) { 94 | list.forEach((tag) => { 95 | if (tag in tags && tag in tagHandlers) { 96 | tagHandlers[tag](tags[tag]); 97 | emit('game/handletag', { [tag]: tags[tag] }); 98 | } 99 | }); 100 | } 101 | 102 | 103 | function defineSceneProcessor(fn) { 104 | sceneProcessors.push(fn); 105 | } 106 | 107 | 108 | function continueStory() { 109 | const { state } = interfaces(); 110 | const { metadata } = state.get(); 111 | // get next scene 112 | const isContinueMaximally = !(metadata.continue_maximally === false); 113 | const scene = ink.getScene(isContinueMaximally); 114 | if (scene.content.length === 0) { 115 | /* 116 | if we have a scene with empty content 117 | (usually it happens after state load) 118 | do not process it further 119 | */ 120 | return; 121 | } 122 | // run scene processors 123 | internalSceneProcessors.forEach((p) => p(scene)); 124 | sceneProcessors.forEach((p) => p(scene)); 125 | // process tags 126 | const { tags } = scene; 127 | 128 | // RESTART 129 | if (tags.RESTART) { 130 | restart(); 131 | return; 132 | } 133 | 134 | // RESTART_FROM_CHECKPOINT 135 | if (tags.RESTART_FROM_CHECKPOINT) { 136 | restart(getSaveSlotKey({ type: 'checkpoint', name: tags.RESTART_FROM_CHECKPOINT })); 137 | return; 138 | } 139 | 140 | // tags to do pre-render actions 141 | $processTags( 142 | ['CLEAR', 'STOP_SOUND', 'STOP_MUSIC', 'PLAY_SOUND', 'PLAY_MUSIC', 'AUDIO', 'AUDIOLOOP'], 143 | tags 144 | ); 145 | 146 | if (metadata.single_scene) { 147 | // put single scene to state 148 | state.setKey('scenes', [scene]); 149 | } else { 150 | // add scene to state 151 | state.appendKey('scenes', scene); 152 | } 153 | 154 | // handle game saves 155 | $processTags( 156 | ['CHECKPOINT', 'SAVEGAME'], 157 | tags 158 | ); 159 | 160 | // if autosave mode is not disabled 161 | if (metadata.autosave !== false) { 162 | saveAutosave(); 163 | } 164 | emit('game/continueStory'); 165 | } 166 | 167 | 168 | export default { 169 | // game-control 170 | init, 171 | loadInkFile, 172 | initInkStory, 173 | start, 174 | clear, 175 | reset, 176 | // game 177 | resume, 178 | canResume, 179 | restart, 180 | restartAndContinue, 181 | continueStory, 182 | makeChoice: (id) => ink.makeChoice(id), 183 | getAssetPath: (path) => interfaces().loader.getAssetPath(path), 184 | defineSceneProcessor, 185 | // saves 186 | SAVE_GAME, 187 | SAVE_AUTOSAVE, 188 | SAVE_CHECKPOINT, 189 | getSaveSlotKey, 190 | saveGame, 191 | saveCheckpoint, 192 | saveAutosave, 193 | load, 194 | listSaves, 195 | removeSave, 196 | existSave, 197 | // sessions 198 | getSession, 199 | setSession, 200 | getSessions, 201 | removeSession 202 | }; 203 | -------------------------------------------------------------------------------- /src/components/game/sessions.js: -------------------------------------------------------------------------------- 1 | import { emit } from '../../utils/emitter'; 2 | import { interfaces } from '../../utils/interfaces'; 3 | 4 | async function $iteratePersistent(callback) { 5 | const { persistent } = interfaces(); 6 | const keys = await persistent.keys(); 7 | await Promise.all( 8 | keys.map( 9 | async (key) => { 10 | if (!key.includes('save')) { 11 | return; // this is not a saved game 12 | } 13 | const saveData = await persistent.get(key); 14 | await callback(key, saveData); 15 | } 16 | ) 17 | ); 18 | } 19 | 20 | // ============================================= 21 | 22 | export function validSession(v) { 23 | if (typeof v === 'number' || typeof v === 'string') { 24 | return v; 25 | } 26 | return ''; 27 | } 28 | 29 | export function getSession() { 30 | const { state } = interfaces(); 31 | return validSession(state.get().game.$sessionID); 32 | } 33 | 34 | export function setSession(sessionName) { 35 | const { state } = interfaces(); 36 | const session = validSession(sessionName); 37 | state.setSubkey('game', '$sessionID', session); 38 | emit('game/setSession', session); 39 | } 40 | 41 | export async function getSessions() { 42 | const sessions = {}; 43 | await $iteratePersistent(async (key, saveData) => { 44 | const sessionID = validSession(saveData.game.$sessionID); 45 | if (sessions[sessionID]) { 46 | sessions[sessionID] += 1; 47 | } else { 48 | sessions[sessionID] = 1; 49 | } 50 | }); 51 | emit('game/getSessions', sessions); 52 | return sessions; 53 | } 54 | 55 | export async function removeSession(sessionName) { 56 | const { persistent } = interfaces(); 57 | const session = validSession(sessionName); 58 | await $iteratePersistent(async (key, saveData) => { 59 | if (validSession(saveData.game.$sessionID) === session) { 60 | await persistent.remove(key); 61 | } 62 | }); 63 | emit('game/deleteSession', session); 64 | } 65 | -------------------------------------------------------------------------------- /src/components/ink.js: -------------------------------------------------------------------------------- 1 | import { parseTags } from '../utils/tags'; 2 | import { getConfig } from '../utils/config'; 3 | import { emit } from '../utils/emitter'; 4 | 5 | let inkStory = null; 6 | 7 | 8 | function initStory(content) { 9 | const { InkStory } = getConfig(); 10 | inkStory = new InkStory(content); 11 | emit('ink/initStory'); 12 | } 13 | 14 | 15 | function resetStory() { 16 | let success = false; 17 | if (inkStory) { 18 | inkStory.ResetState(); 19 | success = true; 20 | } 21 | emit('ink/resetStory', success); 22 | } 23 | 24 | 25 | function $continue(story, scene) { 26 | if (!story.canContinue) { 27 | return; 28 | } 29 | story.Continue(); 30 | const text = story.currentText; 31 | // add story text 32 | scene.text.push(text); 33 | // add tags 34 | const tags = parseTags(story.currentTags); 35 | scene.tags = { ...scene.tags, ...tags }; 36 | // save content - text along with tags 37 | scene.content.push({ text, tags }); 38 | if (text === '\n') { 39 | // there was an empty line, try again to get some text 40 | $continue(story, scene); 41 | } 42 | } 43 | 44 | 45 | // get scene from ink 46 | function getScene(continueMaximally) { 47 | const scene = { 48 | content: [], 49 | text: [], 50 | tags: {}, 51 | choices: [], 52 | uuid: Date.now() 53 | }; 54 | if (continueMaximally) { 55 | while (inkStory.canContinue) { 56 | $continue(inkStory, scene); 57 | } 58 | } else { 59 | $continue(inkStory, scene); 60 | scene.canContinue = inkStory.canContinue; 61 | } 62 | inkStory.currentChoices.forEach((choice, id) => { 63 | scene.choices.push({ 64 | id, 65 | choice: choice.text, 66 | tags: parseTags(choice.tags) 67 | }); 68 | }); 69 | emit('ink/getScene', scene); 70 | return scene; 71 | } 72 | 73 | 74 | export default { 75 | initStory, 76 | resetStory, 77 | story: () => inkStory, 78 | loadState: (savedState) => inkStory.state.LoadJson(savedState), 79 | getState: () => inkStory.state.toJson(), 80 | makeChoice: (id) => { 81 | inkStory.ChooseChoiceIndex(id); 82 | emit('ink/makeChoice', id); 83 | }, 84 | getVisitCount: (ref) => { 85 | const visitCount = inkStory.VisitCountAtPathString(ref); 86 | emit('ink/getVisitCount', { ref, visitCount }); 87 | return visitCount; 88 | }, 89 | evaluateFunction: (fn, args, returnTextOutput) => { 90 | const result = inkStory.EvaluateFunction(fn, args, returnTextOutput); 91 | emit('ink/evaluateFunction', { function: fn, args, result }); 92 | return result; 93 | }, 94 | getGlobalTags: () => { 95 | const globalTags = parseTags(inkStory.globalTags); 96 | emit('ink/getGlobalTags', globalTags); 97 | return globalTags; 98 | }, 99 | getVariable: (name) => { 100 | const value = inkStory.variablesState[name]; 101 | emit('ink/getVariable', { name, value }); 102 | return value; 103 | }, 104 | getVariables: () => { 105 | const varState = inkStory.variablesState; 106 | const inkVars = {}; 107 | varState._globalVariables.forEach(/* eslint-disable-line no-underscore-dangle */ 108 | (value, key) => { inkVars[key] = varState[key]; } 109 | ); 110 | emit('ink/getVariables', inkVars); 111 | return inkVars; 112 | }, 113 | setVariable: (name, value) => { 114 | inkStory.variablesState[name] = value; 115 | emit('ink/setVariable', { name, value }); 116 | }, 117 | observeVariable: (variable, observer) => inkStory.ObserveVariable(variable, observer), 118 | goTo: (knot) => { 119 | inkStory.ChoosePathString(knot); 120 | emit('ink/goTo', knot); 121 | }, 122 | onError: (callback) => { 123 | inkStory.onError = (error) => { 124 | emit('ink/onError', error); 125 | callback(error); 126 | }; 127 | }, 128 | getScene 129 | }; 130 | -------------------------------------------------------------------------------- /src/components/saves.js: -------------------------------------------------------------------------------- 1 | import ink from './ink'; 2 | import { interfaces } from '../utils/interfaces'; 3 | import { emit } from '../utils/emitter'; 4 | 5 | import { validSession } from './game/sessions'; 6 | 7 | export const SAVE_GAME = 'game'; 8 | export const SAVE_AUTOSAVE = 'autosave'; 9 | export const SAVE_CHECKPOINT = 'checkpoint'; 10 | 11 | export function persistentPrefix(section) { 12 | const { $gameUUID, $sessionID } = interfaces().state.get().game; 13 | return [ 14 | $gameUUID, 15 | validSession($sessionID), 16 | section 17 | ].join('/'); 18 | } 19 | 20 | 21 | export function getSaveSlotKey({ name, type }) { 22 | return [ 23 | persistentPrefix('save'), 24 | [SAVE_GAME, SAVE_AUTOSAVE, SAVE_CHECKPOINT].includes(type) ? type : SAVE_GAME, 25 | typeof name === 'string' || typeof name === 'number' ? name : '' 26 | ].join('/'); 27 | } 28 | 29 | 30 | export async function load(s) { 31 | let saveSlotKey = s; 32 | if (typeof s === 'object') { 33 | saveSlotKey = getSaveSlotKey(s); 34 | } 35 | const { persistent, state } = interfaces(); 36 | const gameState = await persistent.get(saveSlotKey); 37 | state.setKey('scenes', gameState.scenes); 38 | state.setKey('game', gameState.game); 39 | ink.loadState(gameState.state); 40 | emit('game/load', saveSlotKey); 41 | } 42 | 43 | 44 | export async function save({ name, type }) { 45 | const { state, persistent } = interfaces(); 46 | const atramentState = state.get(); 47 | const gameState = { 48 | name, 49 | type, 50 | date: Date.now(), 51 | state: ink.getState(), 52 | game: atramentState.game, 53 | scenes: atramentState.scenes 54 | }; 55 | const saveSlotKey = getSaveSlotKey({ name, type }); 56 | await persistent.set(saveSlotKey, gameState); 57 | emit('game/save', saveSlotKey); 58 | } 59 | 60 | 61 | export async function existSave(saveSlotKey) { 62 | const saveExists = await interfaces().persistent.exists(saveSlotKey); 63 | return saveExists; 64 | } 65 | 66 | 67 | export async function removeSave(saveSlotKey) { 68 | await interfaces().persistent.remove(saveSlotKey); 69 | emit('game/removeSave', saveSlotKey); 70 | } 71 | 72 | 73 | export async function listSaves() { 74 | const { persistent } = interfaces(); 75 | const keys = await persistent.keys(); 76 | const saves = keys.filter((k) => k.includes(persistentPrefix('save'))); 77 | const savesList = await Promise.all( 78 | saves.map( 79 | async (key) => { 80 | const saveData = await persistent.get(key); 81 | saveData.id = key; 82 | delete saveData.state; 83 | delete saveData.scenes; 84 | return saveData; 85 | } 86 | ) 87 | ); 88 | emit('game/listSaves', savesList); 89 | return savesList; 90 | } 91 | -------------------------------------------------------------------------------- /src/components/settings.js: -------------------------------------------------------------------------------- 1 | import { interfaces } from '../utils/interfaces'; 2 | import { getConfig } from '../utils/config'; 3 | import { emit } from '../utils/emitter'; 4 | 5 | const handlers = { 6 | mute: (oldValue, newValue) => interfaces().sound.mute(newValue), 7 | volume: (oldValue, newValue) => interfaces().sound.setVolume(newValue) 8 | }; 9 | 10 | async function load() { 11 | const { state, persistent } = interfaces(); 12 | // load default values first 13 | const defaultSettings = JSON.parse(JSON.stringify(getConfig().settings)); // TODO use deep copy here 14 | // load values from save if exist 15 | let savedSettings = {}; 16 | const existSavedSettings = await persistent.exists('settings'); 17 | if (existSavedSettings) { 18 | savedSettings = await persistent.get('settings'); 19 | } 20 | // save app settings to state 21 | const settings = { ...defaultSettings, ...savedSettings }; 22 | state.setKey('settings', settings); 23 | runHandlers(); 24 | emit('settings/load', settings); 25 | } 26 | 27 | async function save() { 28 | const { state, persistent } = interfaces(); 29 | const appState = state.get(); 30 | await persistent.set('settings', appState.settings); 31 | emit('settings/save', appState.settings); 32 | } 33 | 34 | function get(name) { 35 | const value = interfaces().state.get().settings[name]; 36 | emit('settings/get', { name, value }); 37 | return value; 38 | } 39 | 40 | function set(name, newValue) { 41 | const { state } = interfaces(); 42 | const oldValue = state.get().settings[name]; 43 | state.setSubkey('settings', name, newValue); 44 | if (handlers[name]) { 45 | handlers[name](oldValue, newValue); 46 | } 47 | emit('settings/set', { name, value: newValue }); 48 | } 49 | 50 | function toggle(name) { 51 | const oldValue = get(name); 52 | set(name, !oldValue); 53 | } 54 | 55 | function defineHandler(key, handlerFn) { 56 | handlers[key] = handlerFn; 57 | } 58 | 59 | function runHandlers() { 60 | const currentSettings = interfaces().state.get().settings; 61 | Object.entries(currentSettings).forEach(([k, v]) => { 62 | if (handlers[k]) { 63 | handlers[k](null, v); 64 | } 65 | }); 66 | } 67 | 68 | export default { 69 | load, 70 | save, 71 | get, 72 | set, 73 | toggle, 74 | defineHandler, 75 | runHandlers 76 | }; 77 | -------------------------------------------------------------------------------- /src/components/sound.js: -------------------------------------------------------------------------------- 1 | import { interfaces } from '../utils/interfaces'; 2 | import toArray from '../utils/to-array'; 3 | 4 | export function playSound(snd) { 5 | const { sound, loader } = interfaces(); 6 | toArray(snd).forEach( 7 | (file) => sound.playSound(loader.getAssetPath(file)) 8 | ); 9 | } 10 | 11 | export function stopSound(snd) { 12 | const { sound, loader } = interfaces(); 13 | toArray(snd).forEach( 14 | (file) => sound.stopSound(file ? loader.getAssetPath(file) : null) 15 | ); 16 | } 17 | 18 | export function playMusic(mus) { 19 | const { sound, state, loader } = interfaces(); 20 | const currentMusic = state.get().game.$currentMusic || []; 21 | toArray(mus).forEach((file) => { 22 | sound.playMusic(loader.getAssetPath(file)); 23 | currentMusic.push(file); 24 | }); 25 | state.setSubkey('game', '$currentMusic', currentMusic); 26 | } 27 | 28 | export function playSingleMusic(mus) { 29 | const { sound, state, loader } = interfaces(); 30 | sound.stopMusic(); 31 | const file = toArray(mus).pop(); 32 | sound.playMusic(loader.getAssetPath(file)); 33 | state.setSubkey('game', '$currentMusic', [file]); 34 | } 35 | 36 | export function stopMusic(mus) { 37 | const { sound, state, loader } = interfaces(); 38 | let currentMusic = state.get().game.$currentMusic || []; 39 | toArray(mus).forEach((file) => { 40 | if (file) { 41 | sound.stopMusic(loader.getAssetPath(file)); 42 | currentMusic = currentMusic.filter((m) => m !== file); 43 | } else { 44 | sound.stopMusic(); 45 | currentMusic = []; 46 | } 47 | }); 48 | state.setSubkey('game', '$currentMusic', currentMusic); 49 | } 50 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { interfaces, defineInterfaces } from './utils/interfaces'; 2 | import { getConfig, setConfig } from './utils/config'; 3 | import { emitter, emit } from './utils/emitter'; 4 | 5 | import game from './components/game'; 6 | import ink from './components/ink'; 7 | import settings from './components/settings'; 8 | 9 | // @atrament/core version 10 | const version = '2.0.0'; 11 | 12 | /* 13 | Initialize engine: 14 | - save global config 15 | - initialize persistent storage 16 | - if persistent settings found: load settings 17 | - save settings to application state 18 | - initialize sound 19 | */ 20 | async function init(InkStory, cfg) { 21 | emit('atrament/init'); 22 | const { persistent } = interfaces(); 23 | // save global configuration 24 | setConfig(InkStory, cfg); 25 | // initialize persistent storage 26 | persistent.init(getConfig().applicationID); 27 | // load app settings from storage 28 | await settings.load(); 29 | } 30 | 31 | export default { 32 | get interfaces() { 33 | return interfaces(); 34 | }, 35 | defineInterfaces, 36 | init, 37 | get state() { 38 | return interfaces().state; 39 | }, 40 | get store() { 41 | return interfaces().state.store(); 42 | }, 43 | on: (event, callback) => emitter.on(event, callback), 44 | off: (event, callback) => emitter.off(event, callback), 45 | // sub-objects 46 | game, 47 | ink, 48 | settings, 49 | version 50 | }; 51 | -------------------------------------------------------------------------------- /src/utils/config.js: -------------------------------------------------------------------------------- 1 | const atramentConfig = { 2 | settings: { 3 | volume: 0, 4 | mute: true 5 | } 6 | }; 7 | 8 | export function getConfig() { 9 | return atramentConfig; 10 | } 11 | 12 | export function setConfig(InkStory, cfg = {}) { 13 | if (!InkStory) { 14 | throw new Error('atrament.init: provide ink Story constructor as a first argument!'); 15 | } 16 | if (typeof InkStory !== 'function') { 17 | throw new Error('atrament.init: Story is not a constructor!'); 18 | } 19 | if (!cfg.applicationID) { 20 | throw new Error('atrament.init: config.applicationID is not set!'); 21 | } 22 | atramentConfig.InkStory = InkStory; 23 | Object.entries(cfg).forEach(([k, v]) => { 24 | // TODO use better deep copy here 25 | // TODO merge default config with the new config 26 | atramentConfig[k] = JSON.parse(JSON.stringify(v)); 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/emitter.js: -------------------------------------------------------------------------------- 1 | import mitt from 'mitt'; 2 | 3 | export const emitter = mitt(); 4 | export const emit = (...args) => emitter.emit(...args); 5 | -------------------------------------------------------------------------------- /src/utils/hashcode.js: -------------------------------------------------------------------------------- 1 | /* eslint no-bitwise: "off" */ 2 | 3 | export default function hashCode(str) { 4 | return [...str].reduce((s, c) => Math.imul(31, s) + c.charCodeAt(0) | 0, 0); 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/interfaces.js: -------------------------------------------------------------------------------- 1 | let $interfaces = { 2 | loader: null, 3 | persistent: null, 4 | sound: { 5 | init: () => {}, 6 | mute: () => {}, 7 | isMuted: () => {}, 8 | setVolume: () => {}, 9 | getVolume: () => {}, 10 | playSound: () => {}, 11 | playMusic: () => {}, 12 | stopMusic: () => {} 13 | }, 14 | state: null 15 | }; 16 | 17 | export const interfaces = () => $interfaces; 18 | 19 | export function defineInterfaces(interfaceDefinitions) { 20 | $interfaces = { ...$interfaces, ...interfaceDefinitions }; 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/scene-processors.js: -------------------------------------------------------------------------------- 1 | import toArray from './to-array'; 2 | 3 | function $processTag(scene, tag, store) { 4 | if (!scene[store]) { 5 | scene[store] = []; 6 | } 7 | scene.content = scene.content.map((paragraph) => { 8 | if (!paragraph[store]) { 9 | paragraph[store] = []; 10 | } 11 | const processedTag = paragraph.tags?.[tag]; 12 | if (processedTag) { 13 | const t = toArray(processedTag); 14 | scene[store] = [...scene[store], ...t]; 15 | paragraph[store] = [...paragraph[store], ...t]; 16 | } 17 | return paragraph; 18 | }); 19 | } 20 | 21 | function tagDisabledChoices(scene) { 22 | scene.choices = scene.choices.map((choice) => { 23 | if (choice.tags.UNCLICKABLE || choice.tags.DISABLED || choice.tags.INACTIVE) { 24 | choice.disabled = true; 25 | } 26 | return choice; 27 | }); 28 | } 29 | 30 | export default [ 31 | (scene) => $processTag(scene, 'IMAGE', 'images'), 32 | (scene) => $processTag(scene, 'AUDIO', 'sounds'), 33 | (scene) => $processTag(scene, 'PLAY_SOUND', 'sounds'), 34 | (scene) => $processTag(scene, 'AUDIOLOOP', 'music'), 35 | (scene) => $processTag(scene, 'PLAY_MUSIC', 'music'), 36 | tagDisabledChoices 37 | ]; 38 | -------------------------------------------------------------------------------- /src/utils/tags.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses tags from ink 3 | * 4 | * @export 5 | * @param {*} tags - array of tags 6 | * @returns {{}} key-value dictionary of tags 7 | */ 8 | export function parseTags(tags) { 9 | if (!tags) { 10 | return {}; 11 | } 12 | const tagsObj = {}; 13 | tags.forEach((item) => { 14 | let tagName; 15 | let tagValue; 16 | 17 | const content = item.match(/\s*(\w+)\s*:\s*(.+?)\s*$/); 18 | if (content) { 19 | // tag is in "key: value" format 20 | const [, key, value] = content; 21 | tagName = key; 22 | try { 23 | tagValue = JSON.parse(value); // this is JSON 24 | } catch (e) { 25 | const firstChar = value.substr(0, 1); 26 | if (firstChar === '{' || firstChar === '[') { 27 | console.warn('Malformed JSON tag, ignored.', key, value); /* eslint-disable-line */ 28 | } else { 29 | tagValue = value; 30 | } 31 | } 32 | } else { 33 | tagName = item; // use tag as key name 34 | tagValue = true; 35 | } 36 | 37 | // if there are multiple tags with the same name, 38 | // store them as array 39 | if (Array.isArray(tagsObj[tagName])) { 40 | tagsObj[tagName].push(tagValue); 41 | } else if (tagsObj[tagName]) { 42 | tagsObj[tagName] = [tagsObj[tagName], tagValue]; 43 | } else { 44 | tagsObj[tagName] = tagValue; 45 | } 46 | }); 47 | return tagsObj; 48 | } 49 | -------------------------------------------------------------------------------- /src/utils/to-array.js: -------------------------------------------------------------------------------- 1 | export default function toArray(v) { 2 | return Array.isArray(v) ? v : [v]; 3 | } 4 | --------------------------------------------------------------------------------