├── .eslintrc.json ├── .gitignore ├── .htaccess ├── README.md ├── app ├── app.js ├── attribute │ ├── condition.js │ ├── quantity.js │ ├── rarity.js │ ├── size.js │ └── test │ │ ├── tests.condition.js │ │ ├── tests.quantity.js │ │ ├── tests.rarity.js │ │ └── tests.size.js ├── controller │ ├── controller.js │ ├── formatter.js │ ├── knobs.js │ └── test │ │ ├── tests.controller.js │ │ ├── tests.formatter.js │ │ └── tests.knobs.js ├── dungeon │ ├── draw.js │ ├── generate.js │ ├── grid.js │ ├── legend.js │ ├── map.js │ └── test │ │ ├── tests.draw.js │ │ ├── tests.generate.js │ │ ├── tests.grid.js │ │ ├── tests.legend.js │ │ └── tests.map.js ├── item │ ├── furnishing.js │ ├── generate.js │ ├── item.js │ ├── set.js │ ├── test │ │ ├── tests.generate.js │ │ └── tests.item.js │ └── types │ │ ├── ammo.js │ │ ├── armor.js │ │ ├── chancery.js │ │ ├── clothing.js │ │ ├── coin.js │ │ ├── container.js │ │ ├── food.js │ │ ├── kitchen.js │ │ ├── liquid.js │ │ ├── miscellaneous.js │ │ ├── mysterious.js │ │ ├── mystic.js │ │ ├── potion.js │ │ ├── survival.js │ │ ├── tack.js │ │ ├── tool.js │ │ ├── treasure.js │ │ ├── trinket.js │ │ └── weapon.js ├── name │ └── generate.js ├── pages │ ├── notes.js │ └── test │ │ └── tests.notes.js ├── room │ ├── description.js │ ├── dimensions.js │ ├── door.js │ ├── environment.js │ ├── feature.js │ ├── generate.js │ ├── room.js │ ├── test │ │ ├── tests.description.js │ │ ├── tests.dimensions.js │ │ ├── tests.door.js │ │ ├── tests.environment.js │ │ ├── tests.feature.js │ │ ├── tests.generate.js │ │ └── tests.vegetation.js │ ├── trap.js │ └── vegetation.js ├── ui │ ├── alert.js │ ├── block.js │ ├── button.js │ ├── field.js │ ├── footer.js │ ├── form.js │ ├── icon.js │ ├── link.js │ ├── list.js │ ├── nav.js │ ├── spinner.js │ ├── test │ │ ├── tests.block.js │ │ ├── tests.button.js │ │ ├── tests.field.js │ │ ├── tests.footer.js │ │ ├── tests.form.js │ │ ├── tests.link.js │ │ ├── tests.list.js │ │ ├── tests.nav.js │ │ ├── tests.spinner.js │ │ └── tests.typography.js │ ├── toolbar.js │ └── typography.js ├── unit.js ├── unit │ ├── README.md │ ├── assert.js │ ├── output.js │ ├── run.js │ ├── state.js │ ├── suite.js │ └── test │ │ ├── tests.assert.js │ │ ├── tests.output.js │ │ ├── tests.run.js │ │ └── tests.state.js └── utility │ ├── element.js │ ├── roll.js │ ├── shape.js │ ├── test │ ├── tests.element.js │ ├── tests.roll.js │ ├── tests.shape.js │ ├── tests.tools.js │ └── tests.xhr.js │ ├── tools.js │ └── xhr.js ├── favicon.ico ├── humans.txt ├── img ├── notes │ ├── v0.1.0.jpg │ ├── v0.10.0.jpg │ ├── v0.11.0.jpg │ ├── v0.12.0.jpg │ ├── v0.2.0.jpg │ ├── v0.3.0.jpg │ ├── v0.4.0.jpg │ ├── v0.5.0.jpg │ ├── v0.6.0.jpg │ ├── v0.7.0.jpg │ ├── v0.8.0.jpg │ ├── v0.9.0.jpg │ ├── v1.0.0.jpg │ ├── v1.1.0.jpg │ ├── v1.2.0.jpg │ ├── v1.3.0.jpg │ └── v1.4.0.jpg └── screenshot.jpg ├── index.html ├── robots.txt ├── sitemap.xml ├── style.css ├── tmp └── .gitignore └── unit.html /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "parserOptions": { 7 | "ecmaVersion": 13, 8 | "sourceType": "module" 9 | }, 10 | "rules": { 11 | "comma-dangle": [ 12 | "warn", { 13 | "arrays" : "always-multiline", 14 | "objects": "always-multiline", 15 | "imports": "always-multiline", 16 | "exports": "always-multiline" 17 | } 18 | ], 19 | 20 | "indent": [ "warn", 4, { "SwitchCase": 1 } ], 21 | 22 | "quotes": [ 23 | "warn", 24 | "single", { 25 | "allowTemplateLiterals": true, 26 | "avoidEscape": true 27 | } 28 | ], 29 | 30 | "keyword-spacing" : [ "warn", { "before": true } ], 31 | "no-case-declarations": [ "warn" ], 32 | "no-extra-semi" : [ "warn" ], 33 | "no-shadow" : [ "warn" ], 34 | "no-undef" : [ "error" ], 35 | "no-unused-vars" : [ "warn" ], 36 | "semi" : [ "warn", "always" ] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | sync.sh 3 | /Tools/ 4 | -------------------------------------------------------------------------------- /.htaccess: -------------------------------------------------------------------------------- 1 | FallbackResource /index.html 2 | 3 | Header set Cache-Control "max-age=18000, public" 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JavaScript D&D Generator 2 | 3 | > [!WARNING] 4 | > This codebase is no longer being developed. A complete rebuild from the ground up using React & TypeScript has been developed with the goal of resolving deficiencies in the original architecture and building support for more customization options. Check it out at [dnd.mysticwaffle.com](https://dnd.mysticwaffle.com/)! 5 | 6 | D&D Generator at [v1.mysticwaffle.com](https://v1.mysticwaffle.com/) is a web application, forged by AJ, a Human Sorcerer, written (mostly) in JavaScript. The app implements a procedural generation algorithm to draw Dungeons & Dragons game maps as SVG graphics using user input. The maps are accompanied by randomly generated room descriptions, doorway connections, and items. 7 | 8 | The application has zero 3rd party library dependencies. Functionally is validated on page load by a custom built unit testing framework. The API, which is not included in this repository, servers as a backend for storing user generated content. 9 | 10 | This project is a work in progress with numerous features and configuration options still to come. 11 | 12 | ## Screenshot 13 | 14 | ![D&D Dungeon Generator App](/img/screenshot.jpg) 15 | 16 | ## Application Design 17 | 18 | The app uses native browser support for importing JavaScript modules, and so will only run in modern browsers. 19 | 20 | HTML is generated via template literals. 21 | 22 | A multi-dimensional array is used to represent the map grid. Rooms and doorway connections are procedurally generated on the grid and drawn as an SVG element. Room descriptions, traps, doorways, and items are randomly assigned to each room based on probability tables for attributes such as rarity, condition, and quantity. 23 | 24 | The application is organized as follows, with a top level `/index.html` and `/app/app.js` files that initialize the app and loads scrips in the following directories: 25 | 26 | - `/app/attribute/*` - Contains configurations for reusable attributes such as size and condition 27 | - `/app/controller/*` - Application controller code 28 | - `/app/dungeon/*` - Code related to generating dungeons 29 | - `/app/item/*` - Code related to generating items 30 | - `/app/name/*` - Code related to generating names (WIP, currently disabled) 31 | - `/app/room/*` - Code related to, you guessed it, generating rooms 32 | - `/app/ui/*` - HTML generators 33 | - `/app/unit/*` - Unit testing library 34 | - `/app/utility/*` - Utilities such as randomization and text formatters 35 | 36 | ## Unit Tests 37 | 38 | Because 3rd party libraries have been avoided, a custom unit test framework can be found in the `/unit/*` directory. Tests are run by navigating to `/unit.html` as well as on every page load of the main app. Test output is printed to the browser. 39 | 40 | New unit test suites must be added to the test manifest in `/unit/suite.js`. 41 | 42 | ## Creative Commons 43 | 44 | Want to add a feature? Improve something? Fork this repo and open a pull request. 45 | 46 | I ask you don't use this for commercial use without permission and link attribution back to this repo under the Creative Commons Attribution-NonCommercial license. 47 | 48 | Creative Commons License
D&D Dungeon Generator by Mystic Waffle is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License. 49 | -------------------------------------------------------------------------------- /app/app.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getErrorMessage } from './utility/tools.js'; 4 | import { getTestSummary } from './unit/output.js'; 5 | import { request } from './utility/xhr.js'; 6 | import { toss } from './utility/tools.js'; 7 | import { unitState } from './unit/state.js'; 8 | import suite from './unit/suite.js'; 9 | 10 | import { initController } from './controller/controller.js'; 11 | 12 | import { getFooter } from './ui/footer.js'; 13 | 14 | // -- Type Imports ------------------------------------------------------------- 15 | 16 | /** @typedef {import('./controller/controller.js').Sections} Sections */ 17 | 18 | // -- Types -------------------------------------------------------------------- 19 | 20 | /** 21 | * @typedef {object} HistoryEntryState 22 | * 23 | * @prop {string} path 24 | */ 25 | 26 | // -- Functions ---------------------------------------------------------------- 27 | 28 | /** 29 | * Logs an error to the server. 30 | * 31 | * @param {Error | object | string} error 32 | */ 33 | function logError(error) { 34 | console.error(error); 35 | 36 | request('/api/log/error', { 37 | callback: (response) => response.error && console.error(response), 38 | data : { error: getErrorMessage(error) }, 39 | method : 'POST', 40 | }); 41 | } 42 | 43 | // -- Config ------------------------------------------------------------------- 44 | 45 | const sections = (/** @type {() => Sections} */ () => { 46 | let body = document.body; 47 | let content = document.getElementById('content'); 48 | let footer = document.getElementById('footer'); 49 | let knobs = document.getElementById('knobs'); 50 | let nav = document.getElementById('nav'); 51 | let overlay = document.getElementById('overlay'); 52 | let toast = document.getElementById('toast'); 53 | let toolbar = document.getElementById('toolbar'); 54 | 55 | if (!content) { toss('Cannot find content element'); } 56 | if (!footer) { toss('Cannot find footer element'); } 57 | if (!knobs) { toss('Cannot find knobs element'); } 58 | if (!nav) { toss('Cannot find nav element'); } 59 | if (!overlay) { toss('Cannot find nav element'); } 60 | if (!toast) { toss('Cannot find nav element'); } 61 | if (!toolbar) { toss('Cannot find toolbar element'); } 62 | 63 | return { body, content, footer, knobs, nav, overlay, toast, toolbar }; 64 | })(); 65 | 66 | /** 67 | * Whether unit tests should be skipped when loading the app. 68 | * 69 | * @type {boolean} 70 | */ 71 | const skipTests = true; 72 | 73 | // -- Tests -------------------------------------------------------------------- 74 | 75 | const testSummaryLink = getTestSummary(skipTests, console.error, unitState(), suite); 76 | 77 | // -- Router Functions --------------------------------------------------------- 78 | 79 | /** 80 | * Returns the current route 81 | * 82 | * @returns {string} 83 | */ 84 | function getPathname() { 85 | return window.location.pathname; 86 | } 87 | 88 | /** 89 | * Updates the app's URL path. 90 | * 91 | * @param {string} path 92 | */ 93 | function updatePath(path) { 94 | /** @type {HistoryEntryState} */ 95 | let entry = { path }; 96 | 97 | window.history.pushState(entry, '', path); 98 | } 99 | 100 | // -- Initialization ----------------------------------------------------------- 101 | 102 | // TODO move footer render into init controller 103 | const { render } = initController({ 104 | getPathname, 105 | onError: logError, 106 | request, 107 | sections, 108 | updatePath, 109 | }); 110 | 111 | // -- Router Listener ---------------------------------------------------------- 112 | 113 | // TODO move into controller or new "listener" script 114 | window.addEventListener('popstate', (event) => { 115 | event.state && event.state.path && render(event.state.path); 116 | }); 117 | 118 | // -- Initial Render ----------------------------------------------------------- 119 | 120 | sections.footer.innerHTML = getFooter(testSummaryLink); 121 | 122 | render(getPathname()); 123 | -------------------------------------------------------------------------------- /app/attribute/condition.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { createProbability } from '../utility/roll.js'; 4 | 5 | // -- Types -------------------------------------------------------------------- 6 | 7 | /** @typedef {typeof conditions[number]} Condition */ 8 | 9 | // -- Config ------------------------------------------------------------------- 10 | 11 | export const conditions = Object.freeze(/** @type {const} */ ([ 12 | 'decaying', 13 | 'busted', 14 | 'poor', 15 | 'average', 16 | 'good', 17 | 'exquisite', 18 | ])); 19 | 20 | /** 21 | * Condition probability. 22 | * 23 | * @type {Readonly<{ 24 | * description: string; 25 | * roll: () => Condition; 26 | * }>} 27 | */ 28 | export const probability = createProbability(new Map([ 29 | [ 50, 'average' ], 30 | [ 60, 'good' ], 31 | [ 75, 'poor' ], 32 | [ 85, 'busted' ], 33 | [ 95, 'decaying' ], 34 | [ 100, 'exquisite' ], 35 | ])); 36 | -------------------------------------------------------------------------------- /app/attribute/quantity.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { createProbability } from '../utility/roll.js'; 4 | import { createRangeLookup } from '../utility/tools.js'; 5 | 6 | // -- Type Imports ------------------------------------------------------------- 7 | 8 | /** @typedef {import('../utility/tools.js').Range} Range */ 9 | 10 | // -- Types -------------------------------------------------------------------- 11 | 12 | /** @typedef {typeof quantities[number]} Quantity */ 13 | 14 | // -- Config ------------------------------------------------------------------- 15 | 16 | export const quantities = Object.freeze(/** @type {const} */ ([ 17 | 'zero', 18 | 'one', 19 | 'couple', 20 | 'few', 21 | 'some', 22 | 'several', 23 | 'many', 24 | 'numerous', 25 | ])); 26 | 27 | /** 28 | * Quantity probability. 29 | * 30 | * @type {Readonly<{ 31 | * description: string; 32 | * roll: () => Quantity; 33 | * }>} 34 | */ 35 | export const probability = createProbability(new Map([ 36 | [ 5, 'zero' ], 37 | [ 10, 'one' ], 38 | [ 15, 'couple' ], 39 | [ 20, 'few' ], 40 | [ 40, 'some' ], 41 | [ 65, 'several' ], 42 | [ 96, 'many' ], 43 | [ 100, 'numerous' ], 44 | ])); 45 | 46 | /** 47 | * A lookup of range counts for each quantity. 48 | */ 49 | export const quantityRanges = Object.freeze( 50 | /** @type {{ [quantity in Quantity]: Range }} */ ( 51 | createRangeLookup({ 52 | zero : 0, 53 | one : 1, 54 | couple : 2, 55 | few : 3, 56 | some : 5, 57 | several : 8, 58 | many : 14, 59 | numerous: 25, 60 | }, 100) 61 | ) 62 | ); 63 | -------------------------------------------------------------------------------- /app/attribute/rarity.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { createProbability } from '../utility/roll.js'; 4 | 5 | // -- Types -------------------------------------------------------------------- 6 | 7 | /** @typedef {typeof rarities[number]} Rarity */ 8 | 9 | // -- Config ------------------------------------------------------------------- 10 | 11 | export const rarities = Object.freeze(/** @type {const} */ ([ 12 | 'abundant', 13 | 'common', 14 | 'average', 15 | 'uncommon', 16 | 'rare', 17 | 'exotic', 18 | 'legendary', 19 | ])); 20 | 21 | /** 22 | * Indicate rarity 23 | * 24 | * @type {Set} 25 | */ 26 | export const indicateRarity = new Set([ 27 | 'exotic', 28 | 'legendary', 29 | 'rare', 30 | 'uncommon', 31 | ]); 32 | 33 | /** 34 | * Rarity probability. 35 | * 36 | * @type {Readonly<{ 37 | * description: string; 38 | * roll: () => Rarity; 39 | * }>} 40 | */ 41 | export const probability = createProbability(new Map([ 42 | [ 25, 'abundant' ], 43 | [ 45, 'common' ], 44 | [ 65, 'average' ], 45 | [ 80, 'uncommon' ], 46 | [ 93, 'rare' ], 47 | [ 99, 'exotic' ], 48 | [ 100, 'legendary' ], 49 | ])); 50 | -------------------------------------------------------------------------------- /app/attribute/size.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | // -- Types -------------------------------------------------------------------- 4 | 5 | /** @typedef {typeof sizes[number]} Size */ 6 | 7 | // -- Config ------------------------------------------------------------------- 8 | 9 | export const sizes = Object.freeze(/** @type {const} */ ([ 10 | 'tiny', 11 | 'small', 12 | 'medium', 13 | 'large', 14 | 'massive', 15 | ])); 16 | -------------------------------------------------------------------------------- /app/attribute/test/tests.condition.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { 4 | // Config 5 | conditions, 6 | probability, 7 | } from '../condition.js'; 8 | 9 | /** 10 | * @param {import('../../unit/state.js').Utility} utility 11 | */ 12 | export default ({ assert, describe, it }) => { 13 | 14 | // -- Config --------------------------------------------------------------- 15 | 16 | describe('conditions', () => { 17 | it('is an array of strings', () => { 18 | assert(conditions).isArray(); 19 | 20 | conditions.forEach((condition) => { 21 | assert(condition).isString(); 22 | }); 23 | }); 24 | }); 25 | 26 | describe('probability', () => { 27 | it('is a probability object that rolls a condition', () => { 28 | assert(probability.description).isString(); 29 | assert(probability.roll()).isInArray(conditions); 30 | }); 31 | }); 32 | 33 | }; 34 | -------------------------------------------------------------------------------- /app/attribute/test/tests.quantity.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { 4 | // Config 5 | quantities, 6 | probability, 7 | quantityRanges, 8 | } from '../quantity.js'; 9 | 10 | /** 11 | * @param {import('../../unit/state.js').Utility} utility 12 | */ 13 | export default ({ assert, describe, it }) => { 14 | 15 | // -- Config --------------------------------------------------------------- 16 | 17 | describe('quantities', () => { 18 | it('is an array of strings', () => { 19 | assert(quantities).isArray(); 20 | 21 | quantities.forEach((quantity) => { 22 | assert(quantity).isString(); 23 | }); 24 | }); 25 | }); 26 | 27 | describe('probability', () => { 28 | it('is a probability object that rolls a condition', () => { 29 | assert(probability).isObject(); 30 | assert(probability.description).isString(); 31 | assert(probability.roll()).isInArray(quantities); 32 | }); 33 | }); 34 | 35 | describe('quantityRanges', () => { 36 | it('is an object of ranges with a key for each quantity', () => { 37 | assert(quantityRanges).isObject(); 38 | 39 | assert(Object.keys(quantityRanges)).equalsArray(quantities); 40 | Object.values(quantityRanges).forEach(({ min, max }) => { 41 | assert(min).isNumber(); 42 | assert(max).isNumber(); 43 | }); 44 | }); 45 | }); 46 | 47 | }; 48 | -------------------------------------------------------------------------------- /app/attribute/test/tests.rarity.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { 4 | // Config 5 | rarities, 6 | indicateRarity, 7 | probability, 8 | } from '../rarity.js'; 9 | 10 | /** 11 | * @param {import('../../unit/state.js').Utility} utility 12 | */ 13 | export default ({ assert, describe, it }) => { 14 | 15 | // -- Config --------------------------------------------------------------- 16 | 17 | describe('rarities', () => { 18 | it('is an array of strings', () => { 19 | assert(rarities).isArray(); 20 | 21 | rarities.forEach((rarity) => { 22 | assert(rarity).isString(); 23 | }); 24 | }); 25 | }); 26 | 27 | describe('indicateRarity', () => { 28 | it('is a set of rarity strings', () => { 29 | assert(indicateRarity).isSet(); 30 | 31 | indicateRarity.forEach((rarity) => { 32 | assert(rarity).isInArray(rarities); 33 | }); 34 | }); 35 | }); 36 | 37 | describe('probability', () => { 38 | it('is a probability object that rolls a rarity', () => { 39 | assert(probability.description).isString(); 40 | assert(probability.roll()).isInArray(rarities); 41 | }); 42 | }); 43 | 44 | }; 45 | -------------------------------------------------------------------------------- /app/attribute/test/tests.size.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { 4 | // Config 5 | sizes, 6 | } from '../size.js'; 7 | 8 | /** 9 | * @param {import('../../unit/state.js').Utility} utility 10 | */ 11 | export default ({ assert, describe, it }) => { 12 | 13 | // -- Config --------------------------------------------------------------- 14 | 15 | describe('sizes', () => { 16 | it('is an array of strings', () => { 17 | assert(sizes).isArray(); 18 | 19 | sizes.forEach((size) => { 20 | assert(size).isString(); 21 | }); 22 | }); 23 | }); 24 | 25 | }; 26 | -------------------------------------------------------------------------------- /app/dungeon/generate.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { generateMap } from './map.js'; 4 | import { generateRooms } from '../room/generate.js'; 5 | import { getDoorKeys } from '../room/door.js'; 6 | import { roll, rollArrayItem } from '../utility/roll.js'; 7 | import { isRequired, toss } from '../utility/tools.js'; 8 | import trapList from '../room/trap.js'; 9 | 10 | // -- Types -------------------------------------------------------------------- 11 | 12 | /** @typedef {import('../controller/knobs.js').Config} Config */ 13 | /** @typedef {import('../item/generate.js').Item} Item */ 14 | /** @typedef {import('../room/door.js').Door} Door */ 15 | /** @typedef {import('../room/door.js').DoorKey} DoorKey */ 16 | /** @typedef {import('../room/generate').Room} Room */ 17 | /** @typedef {import('./grid.js').Dimensions} Dimensions */ 18 | /** @typedef {import('./map.js').AppliedRoom} AppliedRoom */ 19 | /** @typedef {import('./map.js').DungeonDoors} DungeonDoors */ 20 | 21 | /** 22 | * @typedef {object} Dungeon 23 | * 24 | * @prop {string} name 25 | * @prop {Dimensions} dimensions 26 | * @prop {AppliedRoom[]} rooms 27 | * @prop {Door[]} doors 28 | */ 29 | 30 | // -- Config ------------------------------------------------------------------- 31 | 32 | // TODO minimum complexity constant 33 | 34 | const complexityMultiplierMaxXY = 6; 35 | const complexityMultiplierMinXY = 5; 36 | const complexityRoomCountMultiplier = 10; 37 | const trapCountMultiplier = 5; 38 | 39 | export const maxDungeonMaps = 10; 40 | export const minDungeonMaps = 0; 41 | 42 | export { 43 | complexityMultiplierMaxXY as testComplexityMultiplierMaxXY, 44 | complexityMultiplierMinXY as testComplexityMultiplierMinXY, 45 | complexityRoomCountMultiplier as testComplexityRoomCountMultiplier, 46 | trapCountMultiplier as testTrapCountMultiplier, 47 | }; 48 | 49 | // -- Private Functions -------------------------------------------------------- 50 | 51 | 52 | /** 53 | * Distributes maps throughout the dungeon. 54 | * 55 | * @private 56 | * 57 | * @param {DoorKey[]} keys 58 | * @param {AppliedRoom[]} rooms 59 | */ 60 | function distributeKeys(keys, rooms) { 61 | keys.length && keys.forEach((key) => { 62 | let room = rollArrayItem(rooms); 63 | 64 | if (!room.keys) { 65 | room.keys = []; 66 | } 67 | 68 | room.keys.push(key); 69 | }); 70 | } 71 | 72 | /** 73 | * Distributes maps throughout the dungeon. 74 | * 75 | * @private 76 | * 77 | * @param {number} dungeonMaps 78 | * @param {AppliedRoom[]} rooms 79 | */ 80 | function distributeMaps(dungeonMaps, rooms) { 81 | let count = Math.max(Math.min(maxDungeonMaps, dungeonMaps), minDungeonMaps); 82 | 83 | for (let i = 0; i < count; i++) { 84 | let room = rollArrayItem(rooms); 85 | room.map = true; 86 | } 87 | } 88 | 89 | /** 90 | * Returns generate dungeon room configs. 91 | * 92 | * TODO break out trap generation 93 | * 94 | * @private 95 | * @throws 96 | * 97 | * @param {Config} config 98 | * 99 | * @returns {Room[]} 100 | */ 101 | function generateDungeonRooms(config) { 102 | if (!config.maps) { 103 | toss('config.maps is required in generateDungeonRooms()'); 104 | } 105 | 106 | if (!config.rooms) { 107 | toss('config.rooms is required in generateDungeonRooms()'); 108 | } 109 | 110 | let { 111 | dungeonComplexity, 112 | dungeonTraps, 113 | } = config.maps; 114 | 115 | let rooms = generateRooms({ 116 | ...config, 117 | rooms: { 118 | ...config.rooms, 119 | roomCount: getMaxRoomCount(dungeonComplexity), 120 | }, 121 | }); 122 | 123 | let traps = generateTraps(dungeonTraps); 124 | 125 | // TODO break out into distribute traps function 126 | traps.length && traps.forEach((trap) => { 127 | let room = rollArrayItem(rooms); 128 | 129 | if (!room.traps) { 130 | room.traps = []; 131 | } 132 | 133 | room.traps.push(trap); 134 | }); 135 | 136 | return rooms; 137 | } 138 | 139 | /** 140 | * Returns a maximum grid width and height for the dungeon. 141 | * 142 | * @private 143 | * 144 | * @param {number} complexity 145 | * 146 | * @returns {Dimensions} 147 | */ 148 | function generateMapDimensions(complexity) { 149 | let dimensionMin = complexity * complexityMultiplierMinXY; 150 | let dimensionMax = complexity * complexityMultiplierMaxXY; 151 | 152 | let width = roll(dimensionMin, dimensionMax); 153 | let height = roll(dimensionMin, dimensionMax); 154 | 155 | return { 156 | width, 157 | height, 158 | }; 159 | } 160 | 161 | /** 162 | * Returns an array of trap descriptions. 163 | * 164 | * TODO should duplicate traps be able to be placed in the same room? 165 | * 166 | * @private 167 | * 168 | * @param {number} count 169 | * 170 | * @returns {string[]} 171 | */ 172 | function generateTraps(count) { 173 | let traps = []; 174 | 175 | if (count < 1) { 176 | return traps; 177 | } 178 | 179 | let max = count * trapCountMultiplier; 180 | let min = Math.max(1, (max - trapCountMultiplier - count)); 181 | 182 | let trapCount = roll(min, max); 183 | 184 | for (let i = 0; i < trapCount; i++) { 185 | traps.push(rollArrayItem(trapList)); 186 | } 187 | 188 | return traps; 189 | } 190 | 191 | /** 192 | * Returns the maximum room count for the dungeon's complexity. 193 | * 194 | * @private 195 | * 196 | * @param {number} complexity 197 | * 198 | * @returns {number} 199 | */ 200 | function getMaxRoomCount(complexity) { 201 | return complexity * complexityRoomCountMultiplier; 202 | } 203 | 204 | export { 205 | distributeKeys as testDistributeKeys, 206 | distributeMaps as testDistributeMaps, 207 | generateMapDimensions as testGenerateMapDimensions, 208 | generateTraps as testGenerateTraps, 209 | getMaxRoomCount as testGetMaxRoomCount, 210 | }; 211 | 212 | // -- Public Functions --------------------------------------------------------- 213 | 214 | /** 215 | * Returns a dungeon. 216 | * 217 | * TODO 218 | * - Drop `walls` from rooms returned by `generateMap()`, they are only used in 219 | * procedural generation, not restoring maps. 220 | * 221 | * @param {Config} config 222 | * 223 | * @returns {Dungeon} 224 | */ 225 | export function generateDungeon(config) { 226 | if (!config.maps) { 227 | toss('config.maps is required in generateDungeon()'); 228 | } 229 | 230 | let { 231 | dungeonName, 232 | dungeonComplexity, 233 | dungeonConnections, 234 | dungeonMaps, 235 | dungeonTraps, 236 | } = config.maps; 237 | 238 | isRequired(dungeonComplexity, 'dungeonComplexity is required in generateDungeon()'); 239 | isRequired(dungeonConnections, 'dungeonConnections is required in generateDungeon()'); 240 | isRequired(dungeonMaps, 'dungeonMaps is required in generateDungeon()'); 241 | isRequired(dungeonName, 'dungeonName is required in generateDungeon()'); 242 | isRequired(dungeonTraps, 'dungeonTraps is required in generateDungeon()'); 243 | 244 | let gridDimensions = generateMapDimensions(dungeonComplexity); 245 | let roomConfigs = generateDungeonRooms(config); 246 | 247 | let { 248 | dimensions, 249 | rooms, 250 | doors, 251 | } = generateMap(gridDimensions, roomConfigs); 252 | 253 | distributeKeys(getDoorKeys(doors), rooms); 254 | distributeMaps(dungeonMaps, rooms); 255 | 256 | return { 257 | dimensions, 258 | doors, 259 | name: dungeonName, 260 | rooms, 261 | }; 262 | } 263 | -------------------------------------------------------------------------------- /app/dungeon/grid.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { directions } from './map.js'; 4 | import { roll, rollArrayItem } from '../utility/roll.js'; 5 | import { toss } from '../utility/tools.js'; 6 | 7 | // -- Type Imports ------------------------------------------------------------- 8 | 9 | /** @typedef {import('./map.js').Direction} Direction */ 10 | 11 | // -- Types -------------------------------------------------------------------- 12 | 13 | /** 14 | * X and Y coordinates on a Grid. 15 | * 16 | * @typedef {{ x: number; y: number }} Coordinates 17 | */ 18 | 19 | /** 20 | * Width and Height dimensions in Grid units. 21 | * 22 | * @typedef {{ width: number; height: number }} Dimensions 23 | */ 24 | 25 | /** 26 | * A multidimensional array of grid cells, procedurally populated with rooms 27 | * connected by doors. 28 | * 29 | * @typedef {CellValue[][]} Grid 30 | */ 31 | 32 | /** 33 | * A rectangle represented by X and Y Grid coordinates and Width and Height 34 | * Dimensions. 35 | * 36 | * @typedef {Coordinates & Dimensions} Rectangle 37 | */ 38 | 39 | /** 40 | * Cell Values 41 | * 42 | * @typedef {"." | "w" | "d" | "c" | number} CellValue 43 | */ 44 | 45 | // -- Config ------------------------------------------------------------------- 46 | 47 | export const wallSize = 1; 48 | export const cellFeet = 5; 49 | 50 | /** 51 | * Empty cell value. 52 | * 53 | * @type {CellValue} 54 | */ 55 | const cellEmpty = '.'; 56 | 57 | export { cellEmpty as testCellEmpty }; 58 | 59 | // -- Private Functions -------------------------------------------------------- 60 | 61 | /** 62 | * Returns a random direction. 63 | * 64 | * @param {Direction} direction 65 | * @param {{ 66 | * minX: number; 67 | * minY: number; 68 | * maxX: number; 69 | * maxY: number; 70 | * }} coordinateBounds 71 | * 72 | * @returns {Coordinates} 73 | */ 74 | function getRandomPoint(direction, { minX, minY, maxX, maxY }) { 75 | !directions.includes(direction) && toss('Invalid direction in getRandomPoint()'); 76 | 77 | switch (direction) { 78 | case 'north': 79 | return { 80 | x: roll(minX, maxX), 81 | y: minY, 82 | }; 83 | 84 | case 'east': 85 | return { 86 | x: maxX, 87 | y: roll(minY, maxY), 88 | }; 89 | 90 | case 'south': 91 | return { 92 | x: roll(minX, maxX), 93 | y: maxY, 94 | }; 95 | 96 | case 'west': 97 | return { 98 | x: minX, 99 | y: roll(minY, maxY), 100 | }; 101 | } 102 | } 103 | 104 | /** 105 | * Checks if a grid cell is empty. 106 | * 107 | * TODO rename isEmptyArea 108 | * 109 | * @private 110 | * 111 | * @param {Grid} grid 112 | * @param {Rectangle} rectangle 113 | * 114 | * @returns {boolean} 115 | */ 116 | function isEmptyCell(grid, { x, y, width, height }) { 117 | let minX = wallSize; 118 | let minY = wallSize; 119 | let maxX = grid.length - wallSize; 120 | let maxY = grid[0].length - wallSize; 121 | 122 | for (let xCord = x; xCord < (x + width); xCord++) { 123 | for (let yCord = y; yCord < (y + height); yCord++) { 124 | if (xCord < minX || xCord >= maxX) { 125 | return false; 126 | } 127 | 128 | if (yCord < minY || yCord >= maxY) { 129 | return false; 130 | } 131 | 132 | if (grid[xCord][yCord] !== cellEmpty) { 133 | return false; 134 | } 135 | } 136 | } 137 | 138 | return true; 139 | } 140 | 141 | /** 142 | * Checks if the given coordinates are the corner wall of a room. 143 | * 144 | * @private 145 | * 146 | * @param {object} param // TODO 147 | * 148 | * @returns {boolean} 149 | */ 150 | function isRoomCorner({ x, y, minX, minY, maxX, maxY }) { 151 | // TODO remove unnecessary math, pass in Rectangle 152 | let minLeft = minX + wallSize; 153 | let minTop = minY + wallSize; 154 | let minBottom = maxY - wallSize; 155 | let minRight = maxX - wallSize; 156 | 157 | let upperLeft = x <= minLeft && y <= minTop; 158 | let upperRight = x >= minRight && y <= minTop; 159 | let lowerRight = x >= minRight && y >= minBottom; 160 | let lowerLeft = x <= minLeft && y >= minBottom; 161 | 162 | return upperLeft || upperRight || lowerRight || lowerLeft; 163 | } 164 | 165 | export { 166 | getRandomPoint as testGetRandomPoint, 167 | isEmptyCell as testIsEmptyCell, 168 | isRoomCorner as testIsRoomCorner, 169 | }; 170 | 171 | // -- Public Functions --------------------------------------------------------- 172 | 173 | /** 174 | * Returns a multi dimensional array of empty grid cells for the given grid 175 | * dimensions. 176 | * 177 | * @param {Dimensions} dimensions 178 | * 179 | * @returns {Grid} 180 | */ 181 | export const createBlankGrid = ({ width, height }) => 182 | Array(width).fill(null).map(() => 183 | Array(height).fill(cellEmpty)); 184 | 185 | /** 186 | * Returns dimensions for the given grid 187 | * 188 | * @param {Grid} grid 189 | * 190 | * @returns {Dimensions} 191 | */ 192 | export function getGridDimensions(grid) { 193 | let width = grid.length; 194 | let height = grid[0].length; 195 | 196 | return { width, height }; 197 | } 198 | 199 | /** 200 | * Returns a random starting point for the dungeon door along one of the grid 201 | * edges. 202 | * 203 | * @param {Dimensions} gridDimensions 204 | * @param {Dimensions} roomDimensions 205 | * 206 | * @returns {Coordinates} 207 | */ 208 | export function getStartingPoint(gridDimensions, roomDimensions) { 209 | let { width: gridWidth, height: gridHeight } = gridDimensions; 210 | let { width: roomWidth, height: roomHeight } = roomDimensions; 211 | 212 | let minX = wallSize; 213 | let minY = wallSize; 214 | let maxX = gridWidth - roomWidth - wallSize; 215 | let maxY = gridHeight - roomHeight - wallSize; 216 | 217 | maxX < minX && toss(`Invalid gridWidth "${gridWidth}" in getStartingPoint()`); 218 | maxY < minY && toss(`Invalid gridHeight "${gridHeight}" in getStartingPoint()`); 219 | 220 | let direction = rollArrayItem(directions); 221 | 222 | return getRandomPoint(direction, { minX, minY, maxX, maxY }); 223 | } 224 | 225 | /** 226 | * Returns an array of valid grid coordinates for connecting two rooms. 227 | * 228 | * @param {Grid} grid 229 | * @param {Dimensions} roomDimensions 230 | * @param {Rectangle} prevRoomRect 231 | * 232 | * @returns {Coordinates[]} 233 | */ 234 | export function getValidRoomConnections(grid, roomDimensions, prevRoomRect) { 235 | let { width: roomWidth, height: roomHeight } = roomDimensions; 236 | 237 | let { 238 | x: prevX, 239 | y: prevY, 240 | width: prevWidth, 241 | height: prevHeight, 242 | } = prevRoomRect; 243 | 244 | // Add extra `wallSize` unit in each direction because rooms are placed 1 245 | // wall unit apart, a space which can be occupied by a door cell. 246 | // TODO simplify by using previous room walls array? 247 | let minX = prevX - roomWidth - wallSize; 248 | let maxX = prevX + prevWidth + wallSize; 249 | 250 | let minY = prevY - roomHeight - wallSize; 251 | let maxY = prevY + prevHeight + wallSize; 252 | 253 | let validCords = []; 254 | 255 | for (let x = minX; x <= maxX; x++) { 256 | for (let y = minY; y <= maxY; y++) { 257 | if (isRoomCorner({ x, y, minX, minY, maxX, maxY })) { 258 | continue; 259 | } 260 | 261 | let valid = isEmptyCell(grid, { 262 | x, y, 263 | width: roomWidth, 264 | height: roomHeight, 265 | }); 266 | 267 | if (!valid) { 268 | continue; 269 | } 270 | 271 | validCords.push({ x, y }); 272 | } 273 | } 274 | 275 | return validCords; 276 | } 277 | -------------------------------------------------------------------------------- /app/dungeon/legend.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { cellFeet } from './grid.js'; 4 | import { drawMap, drawRoom, drawDoor, drawGrid } from './draw.js'; 5 | import { span } from '../ui/typography.js'; 6 | import { list } from '../ui/list.js'; 7 | 8 | // -- Types -------------------------------------------------------------------- 9 | 10 | /** @typedef {import('./grid.js').Dimensions} Dimensions */ 11 | /** @typedef {import('./grid.js').Rectangle} Rectangle */ 12 | /** @typedef {import('./map.js').Direction} Direction */ 13 | /** @typedef {import('./map.js').Door} Door */ 14 | 15 | // -- Public Functions --------------------------------------------------------- 16 | 17 | /** 18 | * Returns an unordered list of labeled map features. 19 | * 20 | * TODO draw legend on its own grid and toggle show/hide. 21 | * 22 | * @returns {string} 23 | */ 24 | export function drawLegend() { 25 | let gridDimensions = { width: 1, height: 1 }; 26 | let rectangle = { x: 0, y: 0, width: 1, height: 1 }; 27 | 28 | /** @type {Omit} */ 29 | let doorBase = { 30 | rectangle, 31 | locked: false, 32 | connect: { 1: { direction: 'east', to: 2 } }, 33 | }; 34 | 35 | let scale = `${cellFeet} x ${cellFeet} ft`; 36 | 37 | let legend = { 38 | [scale] : drawGrid(gridDimensions), 39 | 'Room' : drawRoom(rectangle, { roomNumber: '1' }), 40 | 'Trapped Room': drawRoom(rectangle, { roomNumber: '' }, { hasTraps: true }), 41 | 'Passageway' : drawDoor({ ...doorBase, type: 'passageway' }), 42 | 'Archway' : drawDoor({ ...doorBase, type: 'archway' }), 43 | 'Doorway' : drawDoor({ ...doorBase, type: 'wooden' }), 44 | 'Locked Door' : drawDoor({ ...doorBase, type: 'wooden', locked: true }), 45 | 'Hole' : drawDoor({ ...doorBase, type: 'hole' }), 46 | 'Secret' : drawDoor({ ...doorBase, type: 'secret' }), 47 | 'Concealed' : drawDoor({ ...doorBase, type: 'concealed' }), 48 | }; 49 | 50 | return list(Object.keys(legend).map((key) => { 51 | return drawMap(gridDimensions, legend[key]) + span(key); 52 | }), { 'data-flex': true }); 53 | } 54 | -------------------------------------------------------------------------------- /app/dungeon/test/tests.legend.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { cellFeet } from '../grid.js'; 4 | import { 5 | drawLegend, 6 | } from '../legend.js'; 7 | 8 | /** 9 | * @param {import('../../unit/state.js').Utility} utility 10 | */ 11 | export default ({ assert, describe, it }) => { 12 | describe('drawLegend()', () => { 13 | const legend = drawLegend(); 14 | 15 | it('returns an unordered list', () => { 16 | assert(/(.+?)<\/ul>/.test(legend)).isTrue(); 17 | }); 18 | 19 | it('returns a list item for each legend item', () => { 20 | [ 21 | `${cellFeet} x ${cellFeet} ft`, 22 | 'Room', 23 | 'Trapped Room', 24 | 'Passageway', 25 | 'Archway', 26 | 'Doorway', 27 | 'Locked Door', 28 | 'Hole', 29 | 'Secret', 30 | 'Concealed', 31 | ].forEach((label) => { 32 | const regExp = RegExp(`(.*?)(.*?)${label}(.*?)`); 33 | assert(regExp.test(legend)).isTrue(); 34 | }); 35 | 36 | 37 | }); 38 | }); 39 | }; 40 | -------------------------------------------------------------------------------- /app/item/furnishing.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { capacity } from './types/container.js'; 4 | import { rollArrayItem, createProbability } from '../utility/roll.js'; 5 | 6 | // -- Type Imports ------------------------------------------------------------- 7 | 8 | /** @typedef {import('../room/room.js').RoomType} RoomType */ 9 | /** @typedef {import('./generate.js').Item} Item */ 10 | /** @typedef {import('./item.js').ItemPartial} ItemPartial */ 11 | 12 | // -- Types -------------------------------------------------------------------- 13 | 14 | /** @typedef {keyof furnishing} FurnishingType */ 15 | /** @typedef {typeof furnitureQuantities[number]} FurnitureQuantity */ 16 | 17 | // -- Config ------------------------------------------------------------------- 18 | 19 | /** @type {Omit} */ 20 | const defaults = { 21 | maxCount: 1, 22 | rarity : 'average', 23 | size : 'medium', 24 | type : 'furnishing', 25 | }; 26 | 27 | /** @type {{ [furnishing: string]: ItemPartial }} */ 28 | const furnishing = { 29 | alchemy : { name: 'Alchemy equipment' }, 30 | anvil : { name: 'Anvil' }, 31 | bed : { name: 'Bed', variants: [ 'single', 'double', 'queen', 'king', 'bedroll', 'cot', 'rag pile' ] }, 32 | bench : { name: 'Bench', variants: [ 'wood', 'cushioned', 'stone' ] }, 33 | bookcase : { name: 'Bookcase', capacity: capacity.medium, variants: [ 'wood', 'metal' ] }, 34 | cabinet : { name: 'Cabinet', capacity: capacity.medium }, 35 | carpet : { name: 'Carpet' }, 36 | chair : { name: 'Chair', variants: [ 'armchair', 'wood', 'cushioned', 'stone', 'stool' ] }, 37 | cupboard : { name: 'Cupboard', capacity: capacity.medium }, 38 | desk : { name: 'Desk', capacity: capacity.medium, variants: [ 'wood', 'stone', 'metal' ] }, 39 | dresser : { name: 'Dresser', capacity: capacity.medium }, 40 | firePit : { name: 'Fire pit' }, 41 | fireplace: { name: 'Fireplace' }, 42 | forge : { name: 'Forge' }, 43 | lamp : { name: 'Oil lamp' }, 44 | mirror : { name: 'Mirror, large' }, 45 | painting : { name: 'Painting' }, 46 | pillar : { name: 'Pillar' }, 47 | rack : { name: 'Rack', capacity: capacity.medium, variants: [ 'wood', 'metal' ] }, 48 | shelf : { name: 'Table, small', capacity: capacity.small }, 49 | shrine : { name: 'Shrine' }, 50 | spit : { name: 'Cooking spit' }, 51 | tableLg : { name: 'Table, large', capacity: capacity.medium, variants: [ 'wood', 'stone', 'metal' ] }, 52 | tableSm : { name: 'Table, small', capacity: capacity.small, variants: [ 'wood', 'stone', 'metal' ] }, 53 | tapestry : { name: 'Tapestry' }, 54 | throne : { name: 'Throne' }, 55 | torch : { name: 'Torch' }, 56 | wardrobe : { name: 'Wardrobe', capacity: capacity.medium }, 57 | workbench: { name: 'Workbench', capacity: capacity.medium, variants: [ 'wood', 'stone', 'metal' ] }, 58 | }; 59 | 60 | Object.keys(furnishing).forEach((key) => { 61 | let item = furnishing[key]; 62 | 63 | let label = item.name; 64 | 65 | if (item.variants) { 66 | // TODO this should be randomized for each instance when fetched... 67 | let variant = rollArrayItem(item.variants); 68 | label += `, ${variant}`; 69 | } 70 | 71 | furnishing[key] = /** @type {ItemPartial} */ ({ 72 | ...defaults, 73 | ...item, 74 | label, 75 | }); 76 | }); 77 | 78 | export { furnishing }; 79 | 80 | let { 81 | alchemy, 82 | anvil, 83 | bed, 84 | bench, 85 | bookcase, 86 | cabinet, 87 | carpet, 88 | chair, 89 | cupboard, 90 | desk, 91 | dresser, 92 | firePit, 93 | fireplace, 94 | forge, 95 | lamp, 96 | mirror, 97 | painting, 98 | pillar, 99 | rack, 100 | shelf, 101 | shrine, 102 | spit, 103 | tableLg, 104 | tableSm, 105 | tapestry, 106 | throne, 107 | torch, 108 | wardrobe, 109 | workbench, 110 | } = furnishing; 111 | 112 | /** 113 | * Furniture that typically exists in a specific room type. 114 | */ 115 | export const furnishingByRoomType = { 116 | /* eslint-disable max-len */ 117 | armory : [ anvil, bench, cabinet, forge, lamp, rack, tableLg, shelf, torch, workbench ], 118 | atrium : [ bench, carpet, pillar ], 119 | ballroom : [ bench, carpet, chair, fireplace, lamp, tableLg, tableSm ], 120 | bathhouse : [ bench, rack, shelf ], 121 | bedroom : [ bed, bench, bookcase, carpet, chair, desk, dresser, fireplace, lamp, mirror, tableSm, shelf, shrine, wardrobe ], 122 | chamber : [ bookcase, cabinet, carpet, chair, desk, fireplace, lamp, tableSm, shelf, torch ], 123 | dining : [ bench, cabinet, carpet, chair, cupboard, fireplace, lamp, tableLg, tableSm, spit, torch ], 124 | dormitory : [ bed, carpet, bench, bookcase, chair, cupboard, desk, dresser, fireplace, pillar, rack, tableSm, shelf, torch ], 125 | greatHall : [ bench, carpet, bookcase, fireplace, forge, lamp, pillar, rack, tableLg, throne, shrine, torch ], 126 | hallway : [ carpet, shelf, torch ], 127 | kitchen : [ firePit, fireplace, lamp, rack, tableLg, tableSm, shelf, spit, workbench ], 128 | laboratory: [ alchemy, bench, bookcase, cabinet, carpet, chair, desk, fireplace, lamp, mirror, rack, tableSm, tableLg, shelf, torch, workbench ], 129 | library : [ bench, bookcase, cabinet, carpet, chair, desk, fireplace, lamp, tableLg, tableSm, shelf ], 130 | pantry : [ cabinet, cupboard, rack, shelf ], 131 | parlour : [ bench, bookcase, cabinet, carpet, chair, desk, tableSm ], 132 | room : [ carpet, firePit, tableSm, torch ], 133 | shrine : [ carpet, lamp, shrine, torch ], 134 | smithy : [ anvil, forge, workbench ], 135 | storage : [ cabinet, cupboard, rack, tableSm, shelf ], 136 | study : [ bookcase, cabinet, carpet, chair, desk, lamp, tableSm, shelf ], 137 | throne : [ bench, carpet, lamp, pillar, tableLg, throne, torch ], 138 | torture : [ fireplace, torch, workbench ], 139 | treasury : [ carpet, desk, lamp, mirror, rack, tableLg, tableSm ], 140 | /* eslint-enable max-len */ 141 | }; 142 | 143 | /** 144 | * Furniture that must be included in a specific room type. 145 | */ 146 | export const requiredRoomFurniture = { 147 | armory : [ rack ], 148 | bedroom : [ bed ], 149 | dining : [ tableLg ], 150 | dormitory : [ bed ], 151 | kitchen : [ tableSm, spit ], 152 | laboratory: [ alchemy, workbench ], 153 | library : [ bookcase ], 154 | pantry : [ shelf ], 155 | shrine : [ shrine ], 156 | smithy : [ anvil, forge, workbench ], 157 | storage : [ rack ], 158 | study : [ chair, desk ], 159 | throne : [ throne ], 160 | }; 161 | 162 | /** 163 | * Furniture that can exist in any room type. 164 | */ 165 | export const anyRoomFurniture = [ painting, tapestry ]; 166 | 167 | export const furnitureQuantities = Object.freeze(/** @type {const} */ ([ 168 | 'none', 169 | 'minimum', 170 | 'sparse', 171 | 'average', 172 | 'furnished', 173 | ])); 174 | 175 | /** 176 | * Furnishing quantity probability. 177 | * 178 | * @type {Readonly<{ 179 | * description: string; 180 | * roll: () => FurnitureQuantity; 181 | * }>} 182 | */ 183 | export const probability = createProbability(new Map([ 184 | [ 25, 'none' ], 185 | [ 75, 'minimum' ], 186 | [ 92, 'sparse' ], 187 | [ 98, 'average' ], 188 | [ 100, 'furnished' ], 189 | ])); 190 | 191 | export const furnishingQuantityRanges = { 192 | minimum : 1, 193 | sparse : 2, 194 | average : 4, 195 | furnished: 6, 196 | }; 197 | -------------------------------------------------------------------------------- /app/item/item.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import set from './set.js'; 4 | 5 | // -- Type Imports ------------------------------------------------------------- 6 | 7 | /** @typedef {import('../attribute/condition').Condition} Condition */ 8 | /** @typedef {import('../attribute/rarity.js').Rarity} Rarity */ 9 | /** @typedef {import('../attribute/size.js').Size} Size */ 10 | 11 | // -- Types -------------------------------------------------------------------- 12 | 13 | /** @typedef {typeof itemTypes[number]} ItemType */ 14 | 15 | /** 16 | * @typedef {object} ItemPartial 17 | * 18 | * @prop {string} name 19 | * @prop {Rarity} [rarity] 20 | * @prop {Size} [size] 21 | * @prop {ItemType} [type] 22 | * @prop {number} [maxCount] 23 | * @prop {number} [capacity] - Max number of small items found inside 24 | * @prop {string[]} [variants] - Array of variations 25 | */ 26 | 27 | /** 28 | * @typedef {object} ItemBase 29 | * 30 | * @prop {string} name 31 | * @prop {Rarity} rarity 32 | * @prop {Size} size 33 | * @prop {ItemType} type 34 | * @prop {number} maxCount 35 | * @prop {number} [capacity] - Max number of small items found inside 36 | * @prop {string[]} [variants] - Array of variations 37 | */ 38 | 39 | // -- Config ------------------------------------------------------------------- 40 | 41 | export const itemTypes = Object.freeze(/** @type {const} */ ([ 42 | 'ammo', 43 | 'armor', 44 | 'chancery', 45 | 'clothing', 46 | 'coin', 47 | 'container', 48 | 'food', 49 | 'furnishing', 50 | 'kitchen', 51 | 'liquid', 52 | 'miscellaneous', 53 | 'mysterious', 54 | 'mystic', 55 | 'potion', 56 | 'survival', 57 | 'tack', 58 | 'tool', 59 | 'treasure', 60 | 'trinket', 61 | 'weapon', 62 | ])); 63 | 64 | /** 65 | * Item defaults. 66 | * 67 | * @type {Omit} 68 | */ 69 | const defaults = { 70 | maxCount: 1, 71 | rarity : 'average', 72 | size : 'small', 73 | type : 'miscellaneous', 74 | }; 75 | 76 | /** 77 | * Item rarities that should be indicated in item descriptions. 78 | * 79 | * @type {Set} 80 | */ 81 | export const indicateItemRarity = new Set([ 82 | 'rare', 83 | 'exotic', 84 | 'legendary', 85 | ]); 86 | 87 | /** 88 | * Rarities that should be indicated for a set of items. 89 | * 90 | * @type {Set} 91 | */ 92 | export const indicateItemSetRarity = new Set([ 93 | 'uncommon', 94 | 'rare', 95 | 'exotic', 96 | 'legendary', 97 | ]); 98 | 99 | /** 100 | * Item types that should have their details hidden. 101 | * 102 | * @type {Set} 103 | */ 104 | export const hideItemDetails = new Set([ 105 | 'coin', 106 | 'treasure', 107 | ]); 108 | 109 | /** 110 | * Rarities that should be indicated for a set of items. 111 | * 112 | * @type {Set} 113 | */ 114 | export const hideItemSetCondition = new Set([ 115 | 'average', 116 | ]); 117 | 118 | /** @type {ItemBase} */ 119 | export const mysteriousObject = { 120 | ...defaults, 121 | name: 'Mysterious object', 122 | }; 123 | 124 | /** 125 | * Item configs. 126 | * 127 | * @type {ItemBase[]} 128 | */ 129 | const items = set.map((item) => ({ ...defaults, ...item })); 130 | 131 | /** 132 | * Items grouped by rarity and items grouped by type and rarity. 133 | */ 134 | const { 135 | itemsByRarity, 136 | itemsByType, 137 | } = (() => { 138 | let byRarity = {}; 139 | let byType = {}; 140 | 141 | items.forEach((item) => { 142 | let { rarity, type } = item; 143 | 144 | if (!byRarity[rarity]) { 145 | byRarity[rarity] = []; 146 | } 147 | 148 | if (!byType[type]) { 149 | byType[type] = {}; 150 | } 151 | 152 | if (!byType[type][rarity]) { 153 | byType[type][rarity] = []; 154 | } 155 | 156 | byRarity[rarity].push(item); 157 | byType[type][rarity].push(item); 158 | }); 159 | 160 | return { 161 | itemsByRarity: /** @type {{ [key in Rarity]: ItemPartial[] }} */ (byRarity), 162 | itemsByType: /** @type {{ [key in ItemType]: { [key in Rarity]: ItemPartial[] }}} */ (byType), 163 | }; 164 | })(); 165 | 166 | export { 167 | itemsByRarity, 168 | itemsByType, 169 | }; 170 | -------------------------------------------------------------------------------- /app/item/set.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import ammo from './types/ammo.js'; 4 | import armor from './types/armor.js'; 5 | import chancery from './types/chancery.js'; 6 | import clothing from './types/clothing.js'; 7 | import coin from './types/coin.js'; 8 | import container from './types/container.js'; 9 | import food from './types/food.js'; 10 | import kitchen from './types/kitchen.js'; 11 | import liquid from './types/liquid.js'; 12 | import miscellaneous from './types/miscellaneous.js'; 13 | import mysterious from './types/mysterious.js'; 14 | import mystic from './types/mystic.js'; 15 | import potion from './types/potion.js'; 16 | import survival from './types/survival.js'; 17 | import tack from './types/tack.js'; 18 | import tool from './types/tool.js'; 19 | import treasure from './types/treasure.js'; 20 | import trinket from './types/trinket.js'; 21 | import weapon from './types/weapon.js'; 22 | 23 | // -- Type Imports ------------------------------------------------------------- 24 | 25 | /** @typedef {import('./item.js').ItemPartial} ItemPartial */ 26 | 27 | // -- Config ------------------------------------------------------------------- 28 | 29 | /** @type {ItemPartial[]} */ 30 | export default [ 31 | ...ammo, 32 | ...armor, 33 | ...chancery, 34 | ...clothing, 35 | ...coin, 36 | ...container, 37 | ...food, 38 | ...kitchen, 39 | ...liquid, 40 | ...miscellaneous, 41 | ...mysterious, 42 | ...mystic, 43 | ...potion, 44 | ...survival, 45 | ...tack, 46 | ...tool, 47 | ...treasure, 48 | ...trinket, 49 | ...weapon, 50 | ]; 51 | -------------------------------------------------------------------------------- /app/item/test/tests.item.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { 4 | // Private Constants 5 | itemsByRarity, 6 | itemsByType, 7 | } from '../item.js'; 8 | 9 | /** 10 | * @param {import('../../unit/state.js').Utility} utility 11 | */ 12 | export default ({ assert, describe, it }) => { 13 | 14 | // -- Private Constants ---------------------------------------------------- 15 | 16 | // TODO assert on rarity and type sets 17 | // describe('`groupByRarity`', () => { 18 | // it('should contain an entry for each rarity', () => { 19 | // let rarityKeys = Object.keys(groupByRarity); 20 | 21 | // assert(rarityKeys.length).equals(rarities.length); 22 | // assert(rarityKeys.find((rarity) => !rarities.includes(rarity))) 23 | // .isUndefined(); 24 | // }); 25 | // }); 26 | 27 | // describe('`groupByType`', () => { 28 | // it('should contain an entry for each item type', () => { 29 | // let typeKeys = Object.keys(groupByType); 30 | 31 | // assert(typeKeys.length).equals(itemTypes.length); 32 | // assert(typeKeys.find((rarity) => !itemTypes.includes(rarity))) 33 | // .isUndefined(); 34 | // }); 35 | 36 | // it('should contain an entry for each rarity for each item type', () => { 37 | // Object.values(groupByType).forEach((typeGroup) => { 38 | // let rarityKeys = Object.keys(typeGroup); 39 | 40 | // assert(rarityKeys.length).equals(rarities.length); 41 | // assert(rarityKeys.find((rarity) => !rarities.includes(rarity))) 42 | // .isUndefined(); 43 | // }); 44 | // }); 45 | // }); 46 | 47 | }; 48 | -------------------------------------------------------------------------------- /app/item/types/ammo.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @typedef {import('../item.js').ItemPartial} ItemPartial */ 4 | 5 | /** @type {Omit} */ 6 | const defaults = { 7 | maxCount: 50, 8 | rarity : 'common', 9 | type : 'ammo', 10 | }; 11 | 12 | const ammunition = [ 13 | 'Arrow', 14 | 'Blowgun needle', 15 | 'Crossbow bolt', 16 | 'Sling bullet', 17 | ]; 18 | 19 | /** @type {ItemPartial[]} */ 20 | export default ammunition.map((name) => ({ ...defaults, name })); 21 | -------------------------------------------------------------------------------- /app/item/types/armor.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @typedef {import('../item.js').ItemPartial} ItemPartial */ 4 | 5 | /** @type {Omit} */ 6 | const defaults = { 7 | rarity: 'uncommon', 8 | type : 'armor', 9 | size : 'medium', 10 | }; 11 | 12 | /** @type {{ [name: string]: Partial?}} */ 13 | const armor = { 14 | 'Breastplate' : { rarity: 'rare' }, 15 | 'Chain mail' : { rarity: 'rare' }, 16 | 'Chain shirt' : null, 17 | 'Half plate armor' : { rarity: 'rare' }, 18 | 'Hide armor' : null, 19 | 'Leather armor' : null, 20 | 'Padded armor' : null, 21 | 'Plate armor' : { rarity: 'rare' }, 22 | 'Ring mail armor' : null, 23 | 'Scale mail armor' : null, 24 | 'Shield' : null, 25 | 'Splint armor' : { rarity: 'rare' }, 26 | 'Studded leather armor': null, 27 | }; 28 | 29 | /** @type {ItemPartial[]} */ 30 | export default Object.entries(armor).map(([ name, config ]) => ({ 31 | name, 32 | ...defaults, 33 | ...config, 34 | })); 35 | -------------------------------------------------------------------------------- /app/item/types/chancery.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @typedef {import('../item.js').ItemPartial} ItemPartial */ 4 | 5 | /** @type {Omit} */ 6 | const defaults = { 7 | rarity: 'uncommon', 8 | type : 'chancery', 9 | }; 10 | 11 | /** @type {{ [name: string]: Partial}} */ 12 | const chanceryItems = { 13 | 'Abacus' : { rarity: 'rare' }, 14 | 'Book' : { rarity: 'common' }, 15 | 'Chalk' : { size: 'tiny' }, 16 | 'Hourglass' : { rarity: 'rare' }, 17 | 'Ink pen' : { size: 'tiny' }, 18 | 'Ink' : { size: 'tiny', variants: [ 'vial', 'bottle' ] }, 19 | 'Journal' : { variants: [ 'blank', 'adventurer’s', 'noble person’s', 'hermit’s', 'wizard’s', 'merchant’s' ] }, // eslint-disable-line max-len 20 | 'Letter opener' : { size: 'tiny' }, 21 | 'Paper' : { maxCount: 100 }, 22 | 'Paperweight' : { size: 'tiny' }, 23 | 'Parchment' : { maxCount: 100 }, 24 | 'Pencil' : { size: 'tiny' }, 25 | 'Scale, merchant’s': { rarity: 'rare' }, 26 | 'Scroll' : { size: 'tiny', rarity: 'common' }, 27 | 'Sealing wax' : { size: 'tiny' }, 28 | 'Signet ring' : { size: 'tiny' }, 29 | 'Wax seal' : { size: 'tiny' }, 30 | }; 31 | 32 | /** @type {ItemPartial[]} */ 33 | export default Object.entries(chanceryItems).map(([ name, config ]) => ({ 34 | name, 35 | ...defaults, 36 | ...config, 37 | })); 38 | -------------------------------------------------------------------------------- /app/item/types/clothing.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @typedef {import('../item.js').ItemPartial} ItemPartial */ 4 | 5 | /** @type {Omit} */ 6 | const defaults = { 7 | rarity: 'abundant', 8 | type : 'clothing', 9 | }; 10 | 11 | /** @type {{ [name: string]: Partial?}} */ 12 | const clothing = { 13 | 'Belt' : null, 14 | 'Boots' : { variants: [ 'riding', 'soft', 'combat' ] }, 15 | 'Breaches' : null, 16 | 'Brooch' : null, 17 | 'Cap' : null, 18 | 'Cape' : { variants: [ 'cotton', 'canvas', 'fur', 'silk' ] }, 19 | 'Cloak' : { variants: [ 'cotton', 'canvas', 'fur', 'silk' ] }, 20 | 'Clothes' : { variants: [ 'common', 'costume', 'fine', 'traveler’s' ] }, 21 | 'Gloves' : null, 22 | 'Gown' : null, 23 | 'Hat' : null, 24 | 'Hose' : null, 25 | 'Jacket' : { variants: [ 'leather', 'fur', 'silk' ] }, 26 | 'Mittens' : null, 27 | 'Robes' : { variants: [ 'common', 'embroidered' ] }, 28 | 'Sandals' : null, 29 | 'Sash' : null, 30 | 'Shoes' : null, 31 | 'Surcoat' : null, 32 | 'Tunic' : null, 33 | 'Vest' : { variants: [ 'leather', 'fur', 'silk' ] }, 34 | }; 35 | 36 | /** @type {ItemPartial[]} */ 37 | export default Object.entries(clothing).map(([ name, config ]) => ({ 38 | name, 39 | ...defaults, 40 | ...config, 41 | })); 42 | -------------------------------------------------------------------------------- /app/item/types/coin.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @typedef {import('../item.js').ItemPartial} ItemPartial */ 4 | 5 | /** @type {Omit} */ 6 | const defaults = { 7 | maxCount: 100, 8 | type : 'coin', 9 | rarity : 'uncommon', 10 | }; 11 | 12 | /** @type {{ [name: string]: Partial?}} */ 13 | const coins = { 14 | 'Copper piece' : { rarity: 'common' }, 15 | 'Silver piece' : null, 16 | 'Electrum piece': { rarity: 'exotic' }, 17 | 'Gold piece' : { rarity: 'rare' }, 18 | 'Platinum piece': { rarity: 'exotic' }, 19 | }; 20 | 21 | /** @type {ItemPartial[]} */ 22 | export default Object.entries(coins).map(([ name, config ]) => ({ 23 | name, 24 | ...defaults, 25 | ...config, 26 | })); 27 | -------------------------------------------------------------------------------- /app/item/types/container.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @typedef {import('../item.js').ItemPartial} ItemPartial */ 4 | /** @typedef {import('../../attribute/size.js').Size} Size */ 5 | 6 | /** 7 | * Item size lookup defining the number of small items which can fit into a 8 | * container or furnishing. 1 is equal to a single small item. 9 | * 10 | * @type {{ [key in Size]?: number }} 11 | */ 12 | export const capacity = { 13 | tiny : 0.5, 14 | small : 1, 15 | medium: 5, 16 | large : 10, 17 | }; 18 | 19 | /** 20 | * Item size lookup defining the amount of space required. 1 is equal to a 21 | * single small item. 22 | * 23 | * @type {{ [key in Size]?: number }} 24 | */ 25 | export const itemSizeSpace = { 26 | tiny : 0.5, 27 | small : 1, 28 | }; 29 | 30 | // TODO figure out what exactly this does. 31 | export const maxItemQuantitySmall = 10; 32 | 33 | /** @type {Omit} */ 34 | const defaults = { 35 | rarity: 'common', 36 | size : 'small', 37 | type : 'container', 38 | }; 39 | 40 | /** @type {{ [name: string]: Partial?}} */ 41 | const containers = { 42 | 'Backpack' : { size: 'medium' }, 43 | 'Barrel, large' : { size: 'large' }, 44 | 'Barrel, medium' : { size: 'medium' }, 45 | 'Barrel, small' : null, 46 | 'Basket, large' : { size: 'medium' }, 47 | 'Basket, small' : { size: 'small' }, 48 | 'Belt pouch, large' : { }, 49 | 'Belt pouch, small' : { size: 'tiny' }, 50 | 'Bottle, glass' : { size: 'tiny' }, 51 | 'Bowl' : { variants: [ 'wood', 'stone', 'glass' ] }, 52 | 'Box, large' : { size: 'large', variants: [ 'wood', 'stone', 'metal' ] }, 53 | 'Box, medium' : { size: 'medium', variants: [ 'wood', 'stone', 'metal' ] }, 54 | 'Box, small' : { variants: [ 'wood', 'stone', 'metal' ] }, 55 | 'Bucket' : null, 56 | 'Case, crossbow bolt': null, 57 | 'Case, map or scroll': null, 58 | 'Chest, large' : { size: 'large' }, 59 | 'Chest, medium' : { size: 'medium' }, 60 | 'Chest, small' : null, 61 | 'Component pouch' : { size: 'tiny', rarity: 'rare' }, 62 | 'Crate' : null, 63 | 'Flask' : { size: 'tiny' }, 64 | 'Glass case' : { size: 'medium' }, 65 | 'Jug' : null, 66 | 'Pitcher' : null, 67 | 'Pouch' : { size: 'tiny' }, 68 | 'Quiver' : null, 69 | 'Sack' : null, 70 | 'Tankard' : { size: 'tiny', rarity: 'abundant' }, 71 | 'Vial' : { size: 'tiny', rarity: 'uncommon' }, 72 | 'Wagon' : { size: 'large' }, 73 | 'Waterskin' : null, 74 | }; 75 | 76 | // TODO tests 77 | 78 | /** @type {ItemPartial[]} */ 79 | export default Object.entries(containers).map(([ name, config ]) => ({ 80 | name, 81 | capacity: config && config.size ? capacity[config.size] : capacity.small, 82 | ...defaults, 83 | ...config, 84 | })); 85 | -------------------------------------------------------------------------------- /app/item/types/food.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @typedef {import('../item.js').ItemPartial} ItemPartial */ 4 | 5 | /** @type {Omit} */ 6 | const defaults = { 7 | type: 'food', 8 | }; 9 | 10 | /** @type {{ [name: string]: Partial}} */ 11 | const food = { 12 | 'Bread, loaf' : { maxCount: 3 }, 13 | 'Butter' : { variants: [ 'bucket', 'bowl', 'block' ] }, 14 | 'Cheese, round' : { maxCount: 4 }, 15 | 'Cheese, wedge' : { maxCount: 4 }, 16 | 'Cinnamon, pouch': { size: 'tiny', rarity: 'rare' }, 17 | 'Cloves, pouch' : { size: 'tiny', rarity: 'rare' }, 18 | 'Egg' : { size: 'tiny', maxCount: 24 }, 19 | 'Figs' : { size: 'tiny' }, 20 | 'Fish' : { variants: [ 'salmon', 'trout', 'carp', 'herring' ] }, 21 | 'Flour' : { variants: [ 'pouch', 'bag', 'sack' ] }, 22 | 'Ginger, pouch' : { size: 'tiny', rarity: 'rare' }, 23 | 'Grain' : { maxCount: 10 }, 24 | 'Honey' : { rarity: 'uncommon', maxCount: 10 }, 25 | 'Lentils' : { variants: [ 'pouch', 'bag', 'sack' ] }, 26 | 'Meat' : { variants: [ 'chicken', 'dog', 'horse', 'pig', 'deer', 'sheep', 'goat', 'duck' ] }, 27 | 'Nuts' : { size: 'tiny', maxCount: 50 }, 28 | 'Pepper, pouch' : { size: 'tiny', rarity: 'rare' }, 29 | 'Raisins' : { size: 'tiny', maxCount: 50 }, 30 | 'Rations, dried' : { maxCount: 50 }, 31 | 'Rice' : { variants: [ 'pouch', 'bag', 'sack' ] }, 32 | 'Saffron, pouch' : { size: 'tiny', rarity: 'rare' }, 33 | 'Salt, pouch' : { size: 'tiny', rarity: 'rare' }, 34 | 'Soup' : { variants: [ 'meat', 'vegetables', 'potato' ] }, 35 | 'Spice, pouch' : { size: 'tiny', rarity: 'rare', variants: [ 'exotic', 'rare', 'uncommon' ] }, 36 | 'Sugar, pouch' : { size: 'tiny', rarity: 'rare' }, 37 | 'Vegetables' : { maxCount: 20 }, 38 | 'Wheat' : { variants: [ 'pouch', 'bag', 'sack' ] }, 39 | }; 40 | 41 | /** @type {ItemPartial[]} */ 42 | export default Object.entries(food).map(([ name, config ]) => ({ 43 | name, 44 | ...defaults, 45 | ...config, 46 | })); 47 | -------------------------------------------------------------------------------- /app/item/types/kitchen.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @typedef {import('../item.js').ItemPartial} ItemPartial */ 4 | 5 | /** @type {Omit} */ 6 | const defaults = { 7 | rarity: 'abundant', 8 | type : 'kitchen', 9 | }; 10 | 11 | /** @type {{ [name: string]: Partial?}} */ 12 | const kitchenSupplies = { 13 | 'Basin' : null, 14 | 'Cauldron' : { rarity: 'average' }, 15 | 'Cutting board': null, 16 | 'Fork' : null, 17 | 'Kitchen knife': null, 18 | 'Ladle' : { rarity: 'common' }, 19 | 'Mess kit' : null, 20 | 'Pan, iron' : null, 21 | 'Pot, iron' : null, 22 | 'Soap' : { rarity: 'uncommon' }, 23 | 'Spoon' : null, 24 | 'Steak knife' : null, 25 | 'Tub' : null, 26 | }; 27 | 28 | /** @type {ItemPartial[]} */ 29 | export default Object.entries(kitchenSupplies).map(([ name, config ]) => ({ 30 | name, 31 | ...defaults, 32 | ...config, 33 | })); 34 | -------------------------------------------------------------------------------- /app/item/types/liquid.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @typedef {import('../item.js').ItemPartial} ItemPartial */ 4 | 5 | const variants = [ 6 | 'barrel', 7 | 'beaker', 8 | 'bottle', 9 | 'flask', 10 | 'jar', 11 | 'jug', 12 | 'tankard', 13 | 'vat', 14 | 'vial', 15 | 'waterskin', 16 | ]; 17 | 18 | /** @type {Omit} */ 19 | const defaults = { 20 | rarity : 'uncommon', 21 | type : 'liquid', 22 | variants: variants, 23 | }; 24 | 25 | /** @type {{ [name: string]: Partial?}} */ 26 | const liquids = { 27 | 'Acid' : null, 28 | 'Alchemist’s fire': null, 29 | 'Ale' : null, 30 | 'Antitoxin' : null, 31 | 'Cider, apple' : null, 32 | 'Cider, hard' : null, 33 | 'Grog' : null, 34 | 'Holy water' : null, 35 | 'Oil, lamp' : null, 36 | 'Poison, basic' : null, 37 | 'Poison, deadly' : { rarity: 'rare' }, 38 | 'Water, clean' : { rarity: 'common' }, 39 | 'Water, dirty' : { rarity: 'common' }, 40 | 'Whisky' : null, 41 | 'Wine, common' : null, 42 | 'Wine, fine' : { rarity: 'rare' }, 43 | }; 44 | 45 | /** @type {ItemPartial[]} */ 46 | export default Object.entries(liquids).map(([ name, config ]) => ({ 47 | name, 48 | ...defaults, 49 | ...config, 50 | })); 51 | -------------------------------------------------------------------------------- /app/item/types/miscellaneous.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @typedef {import('../item.js').ItemPartial} ItemPartial */ 4 | 5 | /** @type {Omit} */ 6 | const defaults = { 7 | rarity: 'common', 8 | }; 9 | 10 | /** @type {{ [name: string]: Partial?}} */ 11 | const miscellaneousItems = { 12 | 'Bell' : null, 13 | 'Bone' : { variants: [ 'rib', 'pelvis', 'femur', 'leg', 'arm' ] }, 14 | 'Bones' : { maxCount: 10, size: 'tiny', variants: [ 'finger', 'foot', 'vertebrae' ] }, 15 | 'Bones, pile': { size: 'medium' }, 16 | 'Candle' : { rarity: 'abundant', maxCount: 5 }, 17 | 'Canvas' : { variants: [ '1 foot', '5 feet', '10 feet', '20 foot' ] }, 18 | 'Cloth, bolt': { maxCount: 10, variants: [ 'common', 'fine' ] }, 19 | 'Cotton' : { variants: [ '1 foot', '5 feet', '10 feet', '20 foot' ] }, 20 | 'Hide' : { variants: [ 'wolf', 'bear', 'deer', 'rabbit', 'raccoon', 'beaver' ] }, 21 | 'Incense' : { rarity: 'rare' }, 22 | 'Instrument' : { rarity: 'exotic', variants: [ 'Bagpipes', 'Drum', 'Dulcimer', 'Flute', 'Lute', 'Lyre', 'Horn', 'Pan flute', 'Shawm', 'Viol' ] }, // eslint-disable-line max-len 23 | 'Iron, bar' : { rarity: 'uncommon' }, 24 | 'Linen' : { variants: [ '1 foot', '5 feet', '10 feet', '20 foot' ] }, 25 | 'Manacles' : null, 26 | 'Perfume' : { variants: [ 'vial', 'bottle' ] }, 27 | 'Boulder' : { size: 'large' }, 28 | 'Rock' : { size: 'medium' }, 29 | 'Silk' : { rarity: 'rare', variants: [ '1 foot', '5 feet', '10 feet', '20 foot' ] }, 30 | 'Skull' : null, 31 | 'Stone' : { size: 'tiny' }, 32 | 'String' : { variants: [ '1 foot', '5 feet', '10 feet', '20 foot' ] }, 33 | 'Torch' : null, 34 | 'Totem' : null, 35 | }; 36 | 37 | /** @type {ItemPartial[]} */ 38 | export default Object.entries(miscellaneousItems).map(([ name, config ]) => ({ 39 | name, 40 | ...defaults, 41 | ...config, 42 | })); 43 | -------------------------------------------------------------------------------- /app/item/types/mysterious.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @typedef {import('../item.js').ItemPartial} ItemPartial */ 4 | 5 | /** @type {Omit} */ 6 | const defaults = { 7 | rarity: 'rare', 8 | type : 'mysterious', 9 | }; 10 | 11 | const mysteriousItems = [ 12 | 'Mysterious object', 13 | ]; 14 | 15 | /** @type {ItemPartial[]} */ 16 | export default mysteriousItems.map((name) => ({ ...defaults, name })); 17 | -------------------------------------------------------------------------------- /app/item/types/mystic.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @typedef {import('../item.js').ItemPartial} ItemPartial */ 4 | 5 | /** @type {Omit} */ 6 | const defaults = { 7 | rarity: 'rare', 8 | type : 'mystic', 9 | }; 10 | 11 | /** @type {{ [name: string]: Partial?}} */ 12 | const mysticItems = { 13 | 'Arcane focus' : { variants: [ 'crystal', 'orb', 'rod', 'staff', 'wand' ] }, 14 | 'Druidic focus': null, 15 | 'Holy symbol' : { variants: [ 'amulet', 'emblem', 'reliquary' ] }, 16 | 'Yew wand' : null, 17 | 'Spellbook' : null, 18 | }; 19 | 20 | /** @type {ItemPartial[]} */ 21 | export default Object.entries(mysticItems).map(([ name, config ]) => ({ 22 | name, 23 | ...defaults, 24 | ...config, 25 | })); 26 | -------------------------------------------------------------------------------- /app/item/types/potion.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @typedef {import('../item.js').ItemPartial} ItemPartial */ 4 | 5 | /** @type {Omit} */ 6 | const defaults = { 7 | rarity: 'rare', 8 | type : 'potion', 9 | }; 10 | 11 | // TODO more potions! 12 | const potions = [ 13 | 'Potion of healing', 14 | ]; 15 | 16 | /** @type {ItemPartial[]} */ 17 | export default potions.map((name) => ({ ...defaults, name })); 18 | -------------------------------------------------------------------------------- /app/item/types/survival.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @typedef {import('../item.js').ItemPartial} ItemPartial */ 4 | 5 | /** @type {Omit} */ 6 | const defaults = { 7 | rarity: 'uncommon', 8 | type : 'survival', 9 | }; 10 | 11 | /** @type {{ [name: string]: Partial?}} */ 12 | const survivalEquipment = { 13 | 'Bedroll' : { rarity: 'common' }, 14 | 'Blanket' : { rarity: 'common' }, 15 | 'Climber’s kit' : null, 16 | 'Crampons' : null, 17 | 'Firewood' : { rarity: 'abundant' }, 18 | 'Fishhook' : null, 19 | 'Fishing net, large': { size: 'large' }, 20 | 'Fishing net' : { variants: [ 'small', 'medium' ] }, 21 | 'Fishing tackle' : null, 22 | 'Flint and steel' : null, 23 | 'Hunting trap' : null, 24 | 'Piton' : null, 25 | 'Signal whistle' : null, 26 | 'Tent' : { variants: [ 'one-person', 'two-person', 'pavilion' ] }, 27 | 'Tinderbox' : null, 28 | }; 29 | 30 | /** @type {ItemPartial[]} */ 31 | export default Object.entries(survivalEquipment).map(([ name, config ]) => ({ 32 | name, 33 | ...defaults, 34 | ...config, 35 | })); 36 | -------------------------------------------------------------------------------- /app/item/types/tack.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @typedef {import('../item.js').ItemPartial} ItemPartial */ 4 | 5 | /** @type {Omit} */ 6 | const defaults = { 7 | rarity: 'uncommon', 8 | type : 'tack', 9 | }; 10 | 11 | /** @type {{ [name: string]: Partial?}} */ 12 | const tack = { 13 | 'Barding' : { variants: [ 'chain', 'plage', 'scabb' ] }, 14 | 'Bit and bridle': null, 15 | 'Carriage' : null, 16 | 'Cart' : null, 17 | 'Chariot' : null, 18 | 'Feed' : null, 19 | 'Saddle' : { variants: [ 'Exotic', 'Military', 'Pack', 'Riding' ] }, 20 | 'Saddlebags' : null, 21 | }; 22 | 23 | /** @type {ItemPartial[]} */ 24 | export default Object.entries(tack).map(([ name, config ]) => ({ 25 | name, 26 | ...defaults, 27 | ...config, 28 | })); 29 | -------------------------------------------------------------------------------- /app/item/types/tool.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @typedef {import('../item.js').ItemPartial} ItemPartial */ 4 | 5 | /** @type {Omit} */ 6 | const defaults = { 7 | type: 'tool', 8 | }; 9 | 10 | /** @type {{ [name: string]: Partial?}} */ 11 | const tools = { 12 | 'Alchemist’s supplies' : { rarity: 'rare' }, 13 | 'Ball bearings' : { maxCount: 100 }, 14 | 'Block and tackle' : { size: 'medium' }, 15 | 'Brewer’s supplies' : { rarity: 'uncommon' }, 16 | 'Calligrapher’s supplies': { rarity: 'rare' }, 17 | 'Carpenter’s tools' : { rarity: 'common' }, 18 | 'Cartographer’s tools' : { rarity: 'rare' }, 19 | 'Chain' : { variants: [ 'heavy', 'light' ] }, 20 | 'Cobbler’s tools' : null, 21 | 'Compass' : { rarity: 'rare', size: 'tiny' }, 22 | 'Cook’s utensils' : null, 23 | 'Crowbar' : { size: 'medium' }, 24 | 'Dice set' : null, // TODO variations 25 | 'Disguise kit' : { rarity: 'rare' }, 26 | 'Dragonchess set' : { rarity: 'rare' }, 27 | 'Forgery kit' : { rarity: 'rare' }, 28 | 'Glassblower’s tools' : { rarity: 'rare' }, 29 | 'Grappling hook' : { rarity: 'uncommon' }, 30 | 'Hammer, sledge' : { size: 'large' }, 31 | 'Hammer' : null, 32 | 'Healer’s kit' : { rarity: 'rare' }, 33 | 'Herbalism kit' : { rarity: 'rare' }, 34 | 'Jeweler’s tools' : { rarity: 'rare' }, 35 | 'Ladder' : { size: 'large' , variants: [ 'stepping', '5 foot', '10 foot', '20 foot' ] }, 36 | 'Lantern, bullseye' : null, 37 | 'Lantern, hooded' : null, 38 | 'Leatherworker’s tools' : null, 39 | 'Lock' : { variants: [ 'door', 'bolt', 'combination' ] }, 40 | 'Magnifying glass' : { rarity: 'rare' }, 41 | 'Mason’s tools' : null, 42 | 'Mirror, steel' : { rarity: 'uncommon' }, 43 | 'Navigator’s tools' : { rarity: 'uncommon' }, 44 | 'Painter’s supplies' : null, 45 | 'Pick, miner’s' : { size: 'medium' }, 46 | 'Playing card set' : null, 47 | 'Poisoner’s kit' : { rarity: 'rare' }, 48 | 'Pole, 1 foot' : null, 49 | 'Pole' : { size: 'large' , variants: [ '5 foot', '10 foot', '20 foot' ] }, 50 | 'Potter’s tools' : null, 51 | 'Ram, portable' : { size: 'large' }, 52 | 'Rope, hempen' : { size: 'medium' , variants: [ '10 feet', '25 feet', '50 feet', '100 feet' ] }, 53 | 'Rope, silk' : { size: 'medium' , variants: [ '10 feet', '25 feet', '50 feet', '100 feet' ] }, 54 | 'Sewing kit' : { size: 'tiny' }, 55 | 'Shovel' : { size: 'medium' }, 56 | 'Smith’s tools' : { rarity: 'uncommon' }, 57 | 'Spikes, iron' : { maxCount: 100 }, 58 | 'Spyglass' : { rarity: 'rare' }, 59 | 'Thieves’ tools' : { rarity: 'rare' }, 60 | 'Three-Dragon Antet set' : { rarity: 'rare' }, 61 | 'Tinker’s tools' : { rarity: 'rare' }, 62 | 'Weaver’s tools' : null, 63 | 'Whetstone' : { rarity: 'uncommon' }, 64 | 'Woodcarver’s tools' : null, 65 | 'Wooden staff' : { size: 'medium' }, 66 | }; 67 | 68 | /** @type {ItemPartial[]} */ 69 | export default Object.entries(tools).map(([ name, config ]) => ({ 70 | name, 71 | ...defaults, 72 | ...config, 73 | })); 74 | -------------------------------------------------------------------------------- /app/item/types/treasure.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @typedef {import('../item.js').ItemPartial} ItemPartial */ 4 | 5 | /** @type {Omit} */ 6 | const defaults = { 7 | rarity: 'legendary', 8 | type : 'treasure', 9 | }; 10 | 11 | const treasure = [ 12 | 'Treasure', // TODO more treasure types 13 | ]; 14 | 15 | /** @type {ItemPartial[]} */ 16 | export default treasure.map((name) => ({ ...defaults, name })); 17 | -------------------------------------------------------------------------------- /app/item/types/trinket.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @typedef {import('../item.js').ItemPartial} ItemPartial */ 4 | 5 | /** @type {Omit} */ 6 | const defaults = { 7 | rarity: 'exotic', 8 | type : 'trinket', 9 | }; 10 | 11 | const trinkets = [ 12 | 'A mummified goblin hand', 13 | 'A piece of crystal that faintly glows in the moonlight', 14 | 'A gold coin minted in an unknown land', 15 | 'A diary written in a language you don’t know', 16 | 'A brass ring that never tarnishes', 17 | 'An old chess piece made from glass', 18 | 'A pair of knucklebone dice, each with a skull symbol on the side that would normally show six pips', 19 | 'A small idol depicting a nightmarish creature that gives you unsettling dreams when you sleep near it', 20 | 'A rope necklace from which dangles four mummified elf fingers', 21 | 'The deed for a parcel of land in a realm unknown to you', 22 | 'A 1-ounce block made from an unknown material', 23 | 'A small cloth doll skewered with needles', 24 | 'A tooth from an unknown beast', 25 | 'An enormous scale, perhaps from a dragon', 26 | 'A bright green feather', 27 | 'An old divination card bearing your likeness', 28 | 'A glass orb filled with moving smoke', 29 | 'A 1-pound egg with a bright red shell', 30 | 'A pipe that blows bubbles', 31 | 'A glass jar containing a weird bit of flesh floating in pickling fluid', 32 | 'A tiny gnome-crafted music box that plays a song you dimly remember from your childhood', 33 | 'A small wooden statuette of a smug halfling', 34 | 'A brass orb etched with strange runes', 35 | 'A multicolored stone disk', 36 | 'A tiny silver icon of a raven', 37 | 'A bag containing forty-seven humanoid teeth, one of which is rotten', 38 | 'A shard of obsidian that always feels warm to the touch', 39 | 'A dragon’s bony talon hanging from a plain leather necklace', 40 | 'A pair of old socks', 41 | 'A blank book whose pages refuse to hold ink, chalk, graphite, or any other substance or marking', 42 | 'A silver badge in the shape of a five-pointed star', 43 | 'A knife that belonged to a relative', 44 | 'A glass vial filled with nail clippings', 45 | 'A rectangular metal device with two tiny metal cups on one end that throws sparks when wet', 46 | 'A white, sequined glove sized for a human', 47 | 'A vest with one hundred tiny pockets', 48 | 'A small, weightless stone block', 49 | 'A tiny sketch portrait of a goblin', 50 | 'An empty glass vial that smells of perfume when opened', 51 | 'A gemstone that looks like a lump of coal when examined by anyone but you', 52 | 'A scrap of cloth from an old banner', 53 | 'A rank insignia from a lost legionnaire', 54 | 'A tiny silver bell without a clapper', 55 | 'A mechanical canary inside a gnome-crafted lamp', 56 | 'A tiny chest carved to look like it has numerous feet on the bottom', 57 | 'A dead sprite inside a clear glass bottle', 58 | 'A metal can that has no opening but sounds as if it is filled with liquid', 59 | 'A glass orb filled with water, in which swims a clockwork goldfish', 60 | 'A silver spoon with an M engraved on the handle', 61 | 'A whistle made from gold-colored wood', 62 | 'A dead scarab beetle the size of your hand', 63 | 'Two toy soldiers, one with a missing head', 64 | 'A small box filled with different-sized buttons', 65 | 'A candle that can’t be lit', 66 | 'A tiny cage with no door', 67 | 'An old key', 68 | 'An indecipherable treasure map', 69 | 'A hilt from a broken sword', 70 | 'A rabbit’s foot', 71 | 'A glass eye', 72 | 'A cameo carved in the likeness of a hideous person', 73 | 'A silver skull the size of a coin', 74 | 'An alabaster mask', 75 | 'A pyramid of sticky black incense that smells very bad', 76 | 'A nightcap that, when worn, gives you pleasant dreams', 77 | 'A single caltrop made from bone', 78 | 'A gold monocle frame without the lens', 79 | 'A 1-inch cube, each side painted a different color', 80 | 'A crystal knob from a door', 81 | 'A small packet filled with pink dust', 82 | 'A fragment of a beautiful song, written as musical notes on two pieces of parchment', 83 | 'A silver teardrop earring made from a real teardrop', 84 | 'The shell of an egg painted with scenes of human misery in disturbing detail', 85 | 'A fan that, when unfolded, shows a sleeping cat', 86 | 'A set of bone pipes', 87 | 'A four-leaf clover pressed inside a book discussing manners and etiquette', 88 | 'A sheet of parchment upon which is drawn a complex mechanical contraption', 89 | 'An ornate scabbard that fits no blade you have found so far', 90 | 'An invitation to a party where a murder happened', 91 | 'A bronze pentacle with an etching of a rat’s head in its center', 92 | 'A purple handkerchief embroidered with the name of a powerful archmage', 93 | 'Half of a floorplan for a temple, castle, or some other structure', 94 | 'A bit of folded cloth that, when unfolded, turns into a stylish cap', 95 | 'A receipt of deposit at a bank in a far-flung city', 96 | 'A diary with seven missing pages', 97 | 'An empty silver snuffbox bearing an inscription on the surface that says “dreams”', 98 | 'An iron holy symbol devoted to an unknown god', 99 | 'A book that tells the story of a legendary hero’s rise and fall, with the last chapter missing', 100 | 'A vial of dragon blood', 101 | 'An ancient arrow of elven design', 102 | 'A needle that never bends', 103 | 'An ornate brooch of dwarven design', 104 | 'An empty wine bottle bearing a pretty label', 105 | 'A mosaic tile with a multicolored, glazed surface', 106 | 'A petrified mouse', 107 | 'A black pirate flag adorned with a dragon’s skull and crossbones', 108 | 'A tiny mechanical crab or spider that moves about when it’s not being observed', 109 | 'A glass jar containing lard with a label that reads: “Griffon Grease”', 110 | 'A wooden box with a ceramic bottom that holds a living worm with a head on each end of its body', 111 | 'A metal urn containing the ashes of a hero', 112 | ]; 113 | 114 | /** @type {ItemPartial[]} */ 115 | export default trinkets.map((name) => ({ ...defaults, name })); 116 | -------------------------------------------------------------------------------- /app/item/types/weapon.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @typedef {import('../item.js').ItemPartial} ItemPartial */ 4 | 5 | /** @type {Omit} */ 6 | const defaults = { 7 | type : 'weapon', 8 | rarity: 'uncommon', 9 | size : 'medium', 10 | }; 11 | 12 | /** @type {{ [name: string]: Partial?}} */ 13 | const weapons = { 14 | 'Battleaxe' : { rarity: 'average' }, 15 | 'Blowgun' : null, 16 | 'Caltrops' : { size: 'tiny', maxCount: 20 }, 17 | 'Club' : { rarity: 'abundant' }, 18 | 'Crossbow, hand' : { rarity: 'rare' }, 19 | 'Crossbow, heavy': { rarity: 'rare' }, 20 | 'Crossbow, light': { rarity: 'rare' }, 21 | 'Dagger' : { size: 'small', rarity: 'common' }, 22 | 'Dart' : { size: 'tiny' }, 23 | 'Flail' : null, 24 | 'Glaive' : { size: 'large' }, 25 | 'Greataxe' : { size: 'large' }, 26 | 'Greatclub' : { size: 'large' }, 27 | 'Greatsword' : { size: 'large' }, 28 | 'Halberd' : { size: 'large' }, 29 | 'Handaxe' : null, 30 | 'Javelin' : null, 31 | 'Lance' : null, 32 | 'Light hammer' : null, 33 | 'Longbow' : { size: 'large' }, 34 | 'Longsword' : { size: 'large' }, 35 | 'Mace' : null, 36 | 'Maul' : null, 37 | 'Morningstar' : null, 38 | 'Net' : null, 39 | 'Pike' : null, 40 | 'Quarterstaff' : { size: 'large', rarity: 'common' }, 41 | 'Rapier' : null, 42 | 'Scimitar' : null, 43 | 'Shortbow' : null, 44 | 'Shortsword' : { rarity: 'average' }, 45 | 'Sickle' : null, 46 | 'Sling' : null, 47 | 'Spear' : { size: 'large' }, 48 | 'Trident' : { size: 'large' }, 49 | 'War pick' : null, 50 | 'Warhammer' : { size: 'large' }, 51 | 'Whip' : null, 52 | }; 53 | 54 | /** @type {ItemPartial[]} */ 55 | export default Object.entries(weapons).map(([ name, config ]) => ({ 56 | name, 57 | ...defaults, 58 | ...config, 59 | })); 60 | -------------------------------------------------------------------------------- /app/name/generate.js: -------------------------------------------------------------------------------- 1 | 2 | import { roll, rollArrayItem } from '../utility/roll.js'; 3 | import { capitalize } from '../utility/tools.js'; 4 | 5 | const minSyllables = 1; 6 | const maxSyllables = 6; 7 | 8 | const minSyllableLength = 2; 9 | const maxSyllableLength = 5; 10 | 11 | const vowels = [ 'a', 'e', 'i', 'o', 'u' ]; 12 | const constants = [ 13 | 'b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'n', 'p', 'q', 'r', 's', 14 | 't', 'v', 'w', 'x', 'y', 'z', 15 | ]; 16 | 17 | const shuffle = (array) => array 18 | .map((value) => ({ value, sort: Math.random() })) 19 | .sort((a, b) => a.sort - b.sort) 20 | .map(({ value }) => value); 21 | 22 | function generateSyllable() { 23 | let length = roll(minSyllableLength, maxSyllableLength); 24 | 25 | let syllable = shuffle([ ...Array(length - 1) ].reduce((letters) => { 26 | letters.push(rollArrayItem(constants)); 27 | return letters; 28 | }, [ rollArrayItem(vowels) ])).join(''); 29 | 30 | return syllable; 31 | } 32 | 33 | /** 34 | * 35 | * @param {NameConfig} config 36 | * 37 | * @returns {string} 38 | */ 39 | export function generateName(config) { 40 | let length = roll(minSyllables, maxSyllables); 41 | 42 | let name = [ ...Array(length) ].reduce((syllables) => { 43 | syllables.push(generateSyllable()); 44 | return syllables; 45 | }, []); 46 | 47 | return capitalize(name.join('')); 48 | } 49 | -------------------------------------------------------------------------------- /app/pages/test/tests.notes.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { 4 | // Config 5 | releaseNotes, 6 | currentVersion, 7 | 8 | // Public Functions 9 | formatNotes, 10 | } from '../notes.js'; 11 | 12 | /** 13 | * @param {import('../../unit/state.js').Utility} utility 14 | */ 15 | export default ({ assert, describe, it }) => { 16 | 17 | // -- Config --------------------------------------------------------------- 18 | 19 | describe('releaseNotes', () => { 20 | it('should be an array of sequentially versioned ReleaseNote objects', () => { 21 | assert(releaseNotes).isArray(); 22 | 23 | let prevNote; 24 | 25 | releaseNotes.forEach((note) => { 26 | assert(note).isObject(); 27 | assert(note.title).isString(); 28 | assert(note.version).isString(); 29 | assert(note.commit).isString(); 30 | assert(note.date).isString(); 31 | 32 | if (note.description) { 33 | assert(note.description).isArray(); 34 | note.description.forEach((desc) => assert(desc).isString()); 35 | } 36 | 37 | if (note.details) { 38 | assert(note.details).isArray(); 39 | note.details.forEach((detail) => assert(detail).isString()); 40 | } 41 | 42 | if (prevNote) { 43 | // Notes should be descending from recent to oldest. 44 | assert(new Date(note.date) <= new Date(prevNote.date)).isTrue(); 45 | 46 | let versionParts = note.version.split('.', 3).map((numb) => Number(numb)); 47 | let prevVersionParts = prevNote.version.split('.', 3).map((numb) => Number(numb)); 48 | 49 | assert(versionParts.length).equals(3); 50 | 51 | let [ major, minor, patch ] = versionParts; 52 | let [ prevMajor, prevMinor, prevPatch ] = prevVersionParts; 53 | 54 | if (major === prevMajor) { 55 | if (minor === prevMinor) { 56 | assert(patch).equals(prevPatch - 1); 57 | } else { 58 | assert(minor).equals(prevMinor - 1); 59 | } 60 | } else { 61 | assert(major).equals(prevMajor - 1); 62 | } 63 | } 64 | 65 | prevNote = note; 66 | }); 67 | }); 68 | }); 69 | 70 | describe('currentVersion', () => { 71 | it('should match the latest release note version', () => { 72 | assert(currentVersion).equals(releaseNotes[0].version); 73 | }); 74 | }); 75 | 76 | // -- Public Functions ----------------------------------------------------- 77 | 78 | describe('formatNotes()', () => { 79 | // TODO 80 | }); 81 | 82 | }; 83 | -------------------------------------------------------------------------------- /app/room/dimensions.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { roll } from '../utility/roll.js'; 4 | import { roomTypes } from './room.js'; 5 | import { sizes } from '../attribute/size.js'; 6 | 7 | // -- Type Imports ------------------------------------------------------------- 8 | 9 | /** @typedef {import('../attribute/size').Size} Size */ 10 | /** @typedef {import('../dungeon/grid.js').Dimensions} Dimensions */ 11 | /** @typedef {import('./room.js').RoomType} RoomType */ 12 | /** @typedef {import('../utility/tools.js').Range} Range */ 13 | 14 | // -- Config ------------------------------------------------------------------- 15 | 16 | // TODO rename to not confuse "width" with x-axis 17 | const hallLengthMin = 3; 18 | const hallWidthMin = 1; 19 | const hallWidthMax = 1; 20 | 21 | /** 22 | * Dimension ranges 23 | * 24 | * @type {Readonly<{ [key in Size]: Range }>} 25 | */ 26 | export const roomDimensionRanges = Object.freeze({ 27 | tiny : { min: 2, max: 3 }, 28 | small : { min: 2, max: 4 }, 29 | medium : { min: 2, max: 5 }, 30 | large : { min: 3, max: 10 }, 31 | massive : { min: 5, max: 15 }, 32 | }); 33 | 34 | /** 35 | * A lookup of room sizes by room types. 36 | * 37 | * Room types which are not included in the lookup can be of any size. 38 | * 39 | * TODO make into Sets? 40 | * 41 | * @type {{ [key in RoomType]?: Size[] }} 42 | */ 43 | const roomSizes = Object.freeze({ 44 | ballroom : [ 'medium', 'large', 'massive' ], 45 | bathhouse : [ 'small', 'medium', 'large', 'massive' ], 46 | dining : [ 'small', 'medium', 'large', 'massive' ], 47 | dormitory : [ 'medium', 'large', 'massive' ], 48 | greatHall : [ 'large', 'massive' ], 49 | pantry : [ 'tiny', 'small', 'medium' ], 50 | parlour : [ 'tiny', 'small', 'medium' ], 51 | study : [ 'tiny', 'small', 'medium' ], 52 | throne : [ 'medium', 'large', 'massive' ], 53 | torture : [ 'tiny', 'small', 'medium' ], 54 | }); 55 | 56 | /** 57 | * A lookup of custom room dimension functions. 58 | * 59 | * @type {{ 60 | * hallway: (roomSize: Size, options?: { isHorizontal?: boolean }) => Dimensions 61 | * }} 62 | */ 63 | export const customDimensions = { 64 | hallway: (roomSize, { isHorizontal = roll() } = {}) => { 65 | let { min, max } = roomDimensionRanges[roomSize]; 66 | 67 | let length = roll(Math.max(hallLengthMin, min), max); 68 | let width = roll(hallWidthMin, hallWidthMax); 69 | 70 | let hallWidth = isHorizontal ? length : width; 71 | let hallHeight = isHorizontal ? width : length; 72 | 73 | return { 74 | width: hallWidth, 75 | height: hallHeight, 76 | }; 77 | }, 78 | }; 79 | 80 | /** 81 | * Room sizes by room RoomType. 82 | * 83 | * All room sizes are returned if the room type is not limited to a sub-set of 84 | * sizes defined in `roomSizes`. 85 | * 86 | * Rename to `roomSizesByType` & combine with constant object 87 | * 88 | * @type {{ RoomType: Size[] }} 89 | */ 90 | export const roomTypeSizes = roomTypes.reduce((obj, roomType) => { 91 | obj[roomType] = roomSizes[roomType] || sizes; 92 | 93 | return obj; 94 | }, /** @type {{ RoomType: Size[] }} */ ({})); 95 | 96 | export { 97 | hallLengthMin as testHallLengthMin, 98 | hallWidthMax as testHallWidthMax, 99 | hallWidthMin as testHallWidthMin, 100 | roomSizes as testRoomSizes, 101 | }; 102 | -------------------------------------------------------------------------------- /app/room/door.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { createProbability } from '../utility/roll.js'; 4 | 5 | // TODO move to dungeon/* 6 | 7 | // -- Type Imports ------------------------------------------------------------- 8 | 9 | /** @typedef {import('../dungeon/map.js').Connection} Connection */ 10 | /** @typedef {import('../dungeon/map.js').Door} Door */ 11 | /** @typedef {import('../dungeon/map.js').DungeonDoors} DungeonDoors */ 12 | 13 | // -- Types -------------------------------------------------------------------- 14 | 15 | /** @typedef {typeof doorTypes[number]} DoorType */ 16 | 17 | /** 18 | * @typedef {Object} DoorKey 19 | * 20 | * @prop {Connection} connect 21 | * @prop {DoorType} type 22 | */ 23 | 24 | /** @typedef {() => DoorType} RollDoorType */ 25 | /** @typedef {() => "concealed" | "secret" | undefined} RollSecretDoorType */ 26 | 27 | // -- Config ------------------------------------------------------------------- 28 | 29 | export const doorTypes = Object.freeze(/** @type {const} */ ([ 30 | 'archway', 31 | 'brass', 32 | 'concealed', 33 | 'hole', 34 | 'iron', 35 | 'mechanical', 36 | 'passageway', 37 | 'portal', 38 | 'portcullis', 39 | 'secret', 40 | 'steel', 41 | 'stone', 42 | 'wooden', 43 | ])); 44 | 45 | /** 46 | * Set of door types that should have "doorway" appended to their descriptions. 47 | * 48 | * @type {Set} 49 | */ 50 | export const appendDoorway = new Set([ 51 | 'brass', 52 | 'iron', 53 | 'mechanical', 54 | 'steel', 55 | 'stone', 56 | 'wooden', 57 | ]); 58 | 59 | /** 60 | * Set of door types that should have "passage" appended to their descriptions. 61 | * 62 | * @type {Set} 63 | */ 64 | export const appendPassage = new Set([ 65 | 'concealed', 66 | 'secret', 67 | ]); 68 | 69 | /** 70 | * Set of doorway types that can be locked and hae an associated key. 71 | * 72 | * @type {Set} 73 | */ 74 | export const lockable = new Set([ 75 | 'brass', 76 | 'iron', 77 | 'mechanical', 78 | 'portcullis', 79 | 'steel', 80 | 'stone', 81 | 'wooden', 82 | ]); 83 | 84 | /** 85 | * Door type probability. 86 | * 87 | * @type {Readonly<{ 88 | * description: string; 89 | * roll: RollDoorType; 90 | * }>} 91 | */ 92 | export const probability = createProbability(new Map([ 93 | [ 20, 'passageway' ], 94 | [ 40, 'archway' ], 95 | [ 55, 'hole' ], 96 | [ 60, 'mechanical' ], 97 | [ 65, 'portcullis' ], 98 | [ 75, 'wooden' ], 99 | [ 80, 'steel' ], 100 | [ 85, 'iron' ], 101 | [ 90, 'brass' ], 102 | [ 95, 'stone' ], 103 | [ 100, 'portal' ], 104 | ])); 105 | 106 | /** 107 | * Secret door type probability. 108 | * 109 | * @type {Readonly<{ 110 | * description: string; 111 | * roll: RollSecretDoorType; 112 | * }>} 113 | */ 114 | export const secretProbability = createProbability(new Map([ 115 | [ 15, 'concealed' ], 116 | [ 30, 'secret' ], 117 | ])); 118 | 119 | /** 120 | * Percentile chance that a lockable door will be locked. 121 | */ 122 | export const lockedChance = 25; 123 | 124 | // -- Public Functions --------------------------------------------------------- 125 | 126 | /** 127 | * Returns an array of DoorKey objects for the given doors. 128 | * 129 | * TODO move to generate.js 130 | * TODO tests 131 | * 132 | * @param {Door[]} doors 133 | * 134 | * @returns {DoorKey[]} 135 | */ 136 | export function getDoorKeys(doors) { 137 | /** @type {DoorKey[]} */ 138 | let doorKeys = []; 139 | 140 | doors.forEach((door) => { 141 | if (door.locked) { 142 | doorKeys.push({ 143 | type: door.type, 144 | connect: door.connect, 145 | }); 146 | } 147 | }); 148 | 149 | return doorKeys; 150 | } 151 | 152 | /** 153 | * Returns a lookup of Doors keyed by room number. 154 | * 155 | * TODO move to generate.js 156 | * 157 | * @param {Door[]} doors 158 | * 159 | * @returns {DungeonDoors} 160 | */ 161 | export function getDoorsByRoomNumber(doors) { 162 | /** @type {DungeonDoors} */ 163 | let roomDoors = {}; 164 | 165 | doors.forEach((door) => { 166 | Object.keys(door.connect).forEach((roomNumber) => { 167 | if (!roomDoors[roomNumber]) { 168 | roomDoors[roomNumber] = []; 169 | } 170 | 171 | roomDoors[roomNumber].push(door); 172 | }); 173 | }); 174 | 175 | return roomDoors; 176 | } 177 | -------------------------------------------------------------------------------- /app/room/room.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { createProbability } from '../utility/roll.js'; 4 | 5 | // -- Types -------------------------------------------------------------------- 6 | 7 | /** @typedef {typeof roomTypes[number]} RoomType */ 8 | 9 | // -- Config ------------------------------------------------------------------- 10 | 11 | export const roomTypes = Object.freeze(/** @type {const} */ ([ 12 | 'armory', 13 | 'atrium', 14 | 'ballroom', 15 | 'bathhouse', 16 | 'bedroom', 17 | 'chamber', 18 | 'dining', 19 | 'dormitory', 20 | 'greatHall', 21 | 'hallway', 22 | 'kitchen', 23 | 'laboratory', 24 | 'library', 25 | 'pantry', 26 | 'parlour', 27 | 'prison', 28 | 'room', 29 | 'shrine', 30 | 'smithy', 31 | 'storage', 32 | 'study', 33 | 'throne', 34 | 'torture', 35 | 'treasury', 36 | ])); 37 | 38 | /** 39 | * Set of room types that should have the word "room" appended to their 40 | * descriptions. 41 | * 42 | * @type {Set} 43 | */ 44 | export const appendRoomTypes = new Set([ 45 | 'dining', 46 | 'shrine', 47 | 'storage', 48 | 'throne', 49 | ]); 50 | 51 | /** 52 | * Set of room types that should have the word "room" appended to their 53 | * descriptions. 54 | * 55 | * @type {{ [key in RoomType]?: string }} 56 | */ 57 | export const customRoomLabels = Object.freeze({ 58 | torture: 'torture chamber', 59 | }); 60 | 61 | /** 62 | * Generic room type distribution. 63 | * 64 | * @type {{ 65 | * description: string; 66 | * roll: () => "hallway" | "room" | "random"; 67 | * }} 68 | */ 69 | export const probability = createProbability(new Map([ 70 | [ 40, 'hallway' ], 71 | [ 65, 'room' ], 72 | [ 100, 'random' ], 73 | ])); 74 | -------------------------------------------------------------------------------- /app/room/test/tests.dimensions.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { 4 | // Config 5 | customDimensions, 6 | roomDimensionRanges, 7 | roomTypeSizes, 8 | testHallLengthMin as hallLengthMin, 9 | testHallWidthMax as hallWidthMax, 10 | testHallWidthMin as hallWidthMin, 11 | testRoomSizes as roomSizes, 12 | } from '../dimensions.js'; 13 | 14 | import { roomTypes } from '../room.js'; 15 | import { sizes } from '../../attribute/size.js'; 16 | 17 | /** @typedef {import('../../attribute/size.js').Size} Size */ 18 | /** @typedef {import('../room.js').RoomType} RoomType */ 19 | 20 | /** 21 | * @param {import('../../unit/state.js').Utility} utility 22 | */ 23 | export default ({ assert, describe, it }) => { 24 | 25 | // -- Config --------------------------------------------------------------- 26 | 27 | describe('roomDimensionRanges', () => { 28 | it('has an entry for each size', () => { 29 | assert(Object.keys(roomDimensionRanges)).equalsArray(sizes); 30 | }); 31 | 32 | Object.entries(roomDimensionRanges).forEach(([ size, dimensions ]) => { 33 | describe(size, () => { 34 | it('is keyed by a size', () => { 35 | assert(size).isInArray(sizes); 36 | }); 37 | 38 | it('has min and max number properties', () => { 39 | assert(dimensions.min).isNumber(); 40 | assert(dimensions.max).isNumber(); 41 | }); 42 | 43 | it('min is less than or equal to max', () => { 44 | assert(dimensions.min <= dimensions.max).isTrue(); 45 | }); 46 | }); 47 | }); 48 | }); 49 | 50 | describe('roomSizes', () => { 51 | Object.entries(roomSizes).forEach(([ roomType, allowedSizes ]) => { 52 | describe(roomType, () => { 53 | it('is keyed by a room type', () => { 54 | assert(roomType).isInArray(roomTypes); 55 | }); 56 | 57 | it('contains an array of sizes', () => { 58 | assert(allowedSizes.find((size) => !sizes.includes(size))).isUndefined(); 59 | }); 60 | }); 61 | }); 62 | }); 63 | 64 | 65 | describe('customDimensions', () => { 66 | describe('hallway()', () => { 67 | describe('given a room size of "massive"', () => { 68 | describe('given a falsy isHorizontal flag', () => { 69 | const roomDimensions = customDimensions.hallway('massive', { isHorizontal: false }); 70 | 71 | it('returns a Dimensions object', () => { 72 | assert(roomDimensions).isObject(); 73 | assert(roomDimensions.width).isNumber(); 74 | assert(roomDimensions.height).isNumber(); 75 | }); 76 | 77 | it('has a height greater than or equal to hallLengthMin', () => { 78 | assert(roomDimensions.height >= hallLengthMin).isTrue(); 79 | }); 80 | 81 | it('has a height less than or equal to the room\'s max dimension', () => { 82 | assert(roomDimensions.height <= roomDimensionRanges.massive.max).isTrue(); 83 | }); 84 | 85 | it('has a width less than or equal to hallWidthMax', () => { 86 | assert(roomDimensions.width <= hallWidthMax).isTrue(); 87 | }); 88 | 89 | it('has a width greater than or equal to hallWidthMin', () => { 90 | assert(roomDimensions.width >= hallWidthMin).isTrue(); 91 | }); 92 | 93 | it('width is less than height', () => { 94 | assert(roomDimensions.width < roomDimensions.height).isTrue(); 95 | }); 96 | }); 97 | 98 | describe('given a truthy isHorizontal flag', () => { 99 | const roomDimensions = customDimensions.hallway('massive', { isHorizontal: true }); 100 | 101 | it('returns a Dimensions object', () => { 102 | assert(roomDimensions).isObject(); 103 | assert(roomDimensions.width).isNumber(); 104 | assert(roomDimensions.height).isNumber(); 105 | }); 106 | 107 | it('has a width greater than or equal to hallLengthMin', () => { 108 | assert(roomDimensions.width >= hallLengthMin).isTrue(); 109 | }); 110 | 111 | it('has a width less than or equal to room\'s max dimension', () => { 112 | assert(roomDimensions.width <= roomDimensionRanges.massive.max).isTrue(); 113 | }); 114 | 115 | it('has a height less than or equal to hallWidthMax', () => { 116 | assert(roomDimensions.height <= hallWidthMax).isTrue(); 117 | }); 118 | 119 | it('has a height great than or equal to hallWidthMin', () => { 120 | assert(roomDimensions.height >= hallWidthMin).isTrue(); 121 | }); 122 | 123 | it('height is less than width', () => { 124 | assert(roomDimensions.height < roomDimensions.width).isTrue(); 125 | }); 126 | }); 127 | }); 128 | }); 129 | }); 130 | 131 | describe('roomTypeSizes', () => { 132 | it('has an entry for room type', () => { 133 | assert(Object.keys(roomTypeSizes)).equalsArray(roomTypes); 134 | }); 135 | 136 | Object.entries(roomTypeSizes).forEach(([ room, allowedSizes ]) => { 137 | describe(`room size "${room}"`, () => { 138 | it('is an array', () => { 139 | assert(allowedSizes).isArray(); 140 | }); 141 | 142 | it('contains only valid sizes', () => { 143 | const invalidSizes = allowedSizes.find((roomSize) => !sizes.includes(roomSize)); 144 | assert(invalidSizes).isUndefined(); 145 | }); 146 | }); 147 | }); 148 | }); 149 | }; 150 | -------------------------------------------------------------------------------- /app/room/test/tests.door.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { 4 | // Config 5 | appendDoorway, 6 | appendPassage, 7 | doorTypes, 8 | lockable, 9 | probability, 10 | secretProbability, 11 | 12 | // Public Functions 13 | getDoorKeys, 14 | getDoorsByRoomNumber, 15 | } from '../door.js'; 16 | 17 | /** @typedef {import('../../dungeon/map.js').Door} Door */ 18 | 19 | /** 20 | * @param {import('../../unit/state.js').Utility} utility 21 | */ 22 | export default ({ assert, describe, it }) => { 23 | 24 | // -- Config --------------------------------------------------------------- 25 | 26 | describe('appendDoorway', () => { 27 | it('should be a set of door types', () => { 28 | const invalidDoor = [ ...appendDoorway ].find((door) => !doorTypes.includes(door)); 29 | assert(invalidDoor).isUndefined(); 30 | }); 31 | }); 32 | 33 | describe('appendPassage', () => { 34 | it('should be a set of door types', () => { 35 | const invalidDoor = [ ...appendPassage ].find((door) => !doorTypes.includes(door)); 36 | assert(invalidDoor).isUndefined(); 37 | }); 38 | }); 39 | 40 | describe('doorTypes', () => { 41 | it('should be an array of strings', () => { 42 | assert(doorTypes).isArray(); 43 | 44 | const invalidDoor = Object.values(doorTypes).find((value) => typeof value !== 'string'); 45 | assert(invalidDoor).isUndefined(); 46 | }); 47 | }); 48 | 49 | describe('lockable', () => { 50 | it('should be a set of door types', () => { 51 | const invalidDoor = [ ...lockable ].find((door) => !doorTypes.includes(door)); 52 | assert(invalidDoor).isUndefined(); 53 | }); 54 | }); 55 | 56 | describe('probability', () => { 57 | it('should be a probability object', () => { 58 | assert(probability.description).isString(); 59 | assert(probability.roll).isFunction(); 60 | }); 61 | }); 62 | 63 | 64 | describe('probability', () => { 65 | it('should be a probability object', () => { 66 | assert(probability.description).isString(); 67 | assert(probability.roll).isFunction(); 68 | }); 69 | }); 70 | 71 | describe('secretProbability', () => { 72 | it('should be a probability object', () => { 73 | assert(secretProbability.description).isString(); 74 | assert(secretProbability.roll).isFunction(); 75 | }); 76 | }); 77 | 78 | // -- Public Functions ----------------------------------------------------- 79 | 80 | /** @type {Door[]} */ 81 | const dungeonDoors = [ 82 | { 83 | connect: { 84 | 0: { direction: 'east', to: 1 }, 85 | 1: { direction: 'west', to: 0 }, 86 | }, 87 | locked: false, 88 | rectangle: { x: 0, y: 8, width: 1, height: 2 }, 89 | type: 'brass', 90 | }, 91 | { 92 | connect: { 93 | 1: { direction: 'north', to: 2 }, 94 | 2: { direction: 'south', to: 1 }, 95 | }, 96 | locked: true, 97 | rectangle: { x: 4, y: 9, width: 2, height: 1 }, 98 | type: 'archway', 99 | }, 100 | { 101 | connect: { 102 | 1: { direction: 'east', to: 3 }, 103 | 3: { direction: 'west', to: 1 }, 104 | }, 105 | locked: true, 106 | rectangle: { x: 6, y: 7, width: 1, height: 1 }, 107 | type: 'secret', 108 | }, 109 | ]; 110 | 111 | 112 | describe('getDoorKeys()', () => { 113 | describe('given an array of doors', () => { 114 | it('returns an array of keys for each locked door', () => { 115 | const keys = getDoorKeys(dungeonDoors); 116 | 117 | assert(keys).equalsArray([ 118 | { 119 | type: dungeonDoors[1].type, 120 | connect: dungeonDoors[1].connect, 121 | }, 122 | { 123 | type: dungeonDoors[2].type, 124 | connect: dungeonDoors[2].connect, 125 | }, 126 | ]); 127 | }); 128 | }); 129 | }); 130 | 131 | describe('getDoorsByRoomNumber()', () => { 132 | describe('given an array of doors', () => { 133 | describe('when the door is not locked', () => { 134 | it('should return a DungeonDoors object with Door arrays keyed by room number', () => { 135 | const doors = getDoorsByRoomNumber(dungeonDoors); 136 | 137 | assert(doors).equalsObject({ 138 | 0: [ dungeonDoors[0] ], 139 | 1: [ dungeonDoors[0], dungeonDoors[1], dungeonDoors[2] ], 140 | 2: [ dungeonDoors[1] ], 141 | 3: [ dungeonDoors[2] ], 142 | }); 143 | }); 144 | }); 145 | }); 146 | }); 147 | }; 148 | -------------------------------------------------------------------------------- /app/room/test/tests.environment.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { 4 | // Private Functions 5 | testGetAirDesc as getAirDesc, 6 | testGetGroundDesc as getGroundDesc, 7 | testGetStructureDesc as getStructureDesc, 8 | testGetWallDesc as getWallDesc, 9 | 10 | // Public Functions 11 | getEnvironmentDescription, 12 | structure, 13 | } from '../environment.js'; 14 | 15 | import { getRoomLabel } from '../description.js'; 16 | 17 | /** 18 | * @param {import('../../unit/state.js').Utility} utility 19 | */ 20 | export default ({ assert, describe, it }) => { 21 | 22 | // -- Private Functions ---------------------------------------------------- 23 | 24 | describe('getAirDesc()', () => { 25 | // TODO inject probabilities 26 | // it('should return a string', () => { 27 | // assert(getAirDesc()).isString(); 28 | // }); 29 | }); 30 | 31 | describe('getGroundDesc()', () => { 32 | // TODO inject probabilities 33 | // it('should return a string', () => { 34 | // assert(getGroundDesc()).isString(); 35 | // }); 36 | }); 37 | 38 | describe('getStructureDesc()', () => { 39 | Object.values(structure).forEach((roomStructure) => { 40 | describe(`given a room structure of \`${roomStructure}\``, () => { 41 | const desc = getStructureDesc({ 42 | roomType: 'bedroom', 43 | roomSize: 'medium', 44 | }, roomStructure); 45 | 46 | it('should return a string', () => { 47 | assert(desc).isString(); 48 | }); 49 | 50 | it('should include the room type label', () => { 51 | assert(desc).stringIncludes(getRoomLabel('bedroom')); 52 | }); 53 | }); 54 | }); 55 | 56 | describe('given an invalid structure', () => { 57 | it('should throw', () => { 58 | assert(() => getStructureDesc({ 59 | roomType: 'bedroom', 60 | roomSize: 'medium', 61 | }, 'very small rocks')).throws('Invalid room structure'); 62 | }); 63 | }); 64 | 65 | describe('given a room structure of `structure.cave`', () => { 66 | describe('when the room size is `size.massive`', () => { 67 | it('should include "cavern" and not "cave"', () => { 68 | const desc = getStructureDesc({ 69 | roomType: 'room', 70 | roomSize: 'massive', 71 | }, structure.cave); 72 | 73 | assert(desc).stringIncludes('cavern'); 74 | }); 75 | 76 | describe('when the room type is "hallway"', () => { 77 | it('should include "cave" and not "cavern"', () => { 78 | const desc = getStructureDesc({ 79 | roomType: 'hallway', 80 | roomSize: 'massive', 81 | }, structure.cave); 82 | 83 | assert(desc).stringIncludes('cave'); 84 | assert(desc).stringExcludes('cavern'); 85 | }); 86 | }); 87 | }); 88 | 89 | describe('when the room size is not `size.massive`', () => { 90 | it('should include "cave" and not "cavern"', () => { 91 | const desc = getStructureDesc({ 92 | roomType: 'room', 93 | roomSize: 'medium', 94 | }, structure.cave); 95 | 96 | assert(desc).stringIncludes('cave'); 97 | assert(desc).stringExcludes('cavern'); 98 | }); 99 | }); 100 | }); 101 | }); 102 | 103 | describe('getWallDesc()', () => { 104 | // TODO inject probabilities 105 | // it('should return a string', () => { 106 | // assert(getWallDesc()).isString(); 107 | // }); 108 | }); 109 | 110 | // -- Public Functions ----------------------------------------------------- 111 | 112 | describe('getEnvironmentDescription()', () => { 113 | it('should return an array of strings', () => { 114 | const descriptionParts = getEnvironmentDescription({ 115 | roomType: 'laboratory', 116 | roomSize: 'medium', 117 | }); 118 | 119 | assert(descriptionParts).isArray(); 120 | 121 | const invalidDescription = descriptionParts.find((desc) => typeof desc !== 'string'); 122 | assert(invalidDescription).isUndefined(); 123 | }); 124 | }); 125 | }; 126 | -------------------------------------------------------------------------------- /app/room/test/tests.feature.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { 4 | // Private Functions 5 | testGetFeatureDesc as getFeatureDesc, 6 | 7 | // Public Functions 8 | roomFeatures, 9 | getRoomFeatures, 10 | } from '../feature.js'; 11 | 12 | /** 13 | * @param {import('../../unit/state.js').Utility} utility 14 | */ 15 | export default ({ assert, describe, it }) => { 16 | 17 | // -- Private Functions ---------------------------------------------------- 18 | 19 | describe('getFeatureDesc()', () => { 20 | roomFeatures.forEach((roomFeature) => { 21 | describe(`given a room feature of \`${roomFeature}\``, () => { 22 | it('should return a string', () => { 23 | assert(getFeatureDesc(roomFeature, { variation: false })).isString(); 24 | }); 25 | }); 26 | 27 | describe('variations', () => { 28 | it('should return a string', () => { 29 | assert(getFeatureDesc(roomFeature, { variation: true })).isString(); 30 | }); 31 | }); 32 | }); 33 | 34 | describe('given an invalid room feature', () => { 35 | it('should throw', () => { 36 | // @ts-expect-error 37 | assert(() => getFeatureDesc('captain jim jam')).throws('Invalid room feature'); 38 | }); 39 | }); 40 | }); 41 | 42 | describe('getFeatureDesc()', () => { 43 | describe('given a room type of "hallway"', () => { 44 | it('should return an empty array', () => { 45 | assert(getRoomFeatures({ roomType: 'hallway' })).equalsArray([]); 46 | }); 47 | }); 48 | 49 | // TODO inject probability before adding test coverage. 50 | }); 51 | 52 | // -- Public Functions ----------------------------------------------------- 53 | 54 | describe('getRoomFeatures()', () => { 55 | // TODO 56 | }); 57 | 58 | }; 59 | -------------------------------------------------------------------------------- /app/room/test/tests.vegetation.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { 4 | // Private Functions 5 | testGetDescription as getDescription, 6 | 7 | // Public Functions 8 | getRoomVegetationDescription, // TODO 9 | vegetationType, 10 | } from '../vegetation.js'; 11 | 12 | /** 13 | * @param {import('../../unit/state.js').Utility} utility 14 | */ 15 | export default ({ assert, describe, it }) => { 16 | 17 | // -- Private Functions ---------------------------------------------------- 18 | 19 | describe('getDescription()', () => { 20 | vegetationType.forEach((roomVegetation) => { 21 | describe(`given a room vegetation of \`${roomVegetation}\``, () => { 22 | it('should return a string', () => { 23 | assert(getDescription(roomVegetation, { variation: false })) 24 | .isString(); 25 | }); 26 | }); 27 | 28 | describe('variations', () => { 29 | it('should return a string', () => { 30 | assert(getDescription(roomVegetation, { variation: true })) 31 | .isString(); 32 | }); 33 | }); 34 | }); 35 | 36 | describe('given an invalid room vegetation', () => { 37 | it('should throw', () => { 38 | // @ts-expect-error 39 | assert(() => getDescription('bowling balls')) 40 | .throws('Invalid vegetation type "bowling balls" in getDescription()'); 41 | }); 42 | }); 43 | }); 44 | 45 | // -- Public Functions ----------------------------------------------------- 46 | 47 | describe('getRoomVegetationDescription()', () => { 48 | // TODO inject probability before adding test coverage. 49 | /* 50 | describe('given a count of one', () => { 51 | it('should return a single vegetation description', () => { 52 | assert(getVegetationDescription({}, { count: 1 })).stringExcludes(','); 53 | }); 54 | }); 55 | 56 | describe('given a count of two', () => { 57 | it('should return two vegetation descriptions', () => { 58 | assert(getVegetationDescription({}, { count: 2 })).stringIncludes('and'); 59 | }); 60 | }); 61 | 62 | describe('given a count of three', () => { 63 | it('should return three vegetation descriptions', () => { 64 | assert(getVegetationDescription({}, { count: 3 })) 65 | .stringIncludes(', ') 66 | .stringIncludes('and'); 67 | }); 68 | }); 69 | */ 70 | }); 71 | }; 72 | -------------------------------------------------------------------------------- /app/room/trap.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | // -- Config ------------------------------------------------------------------- 4 | 5 | /** 6 | * Trap descriptions 7 | */ 8 | const trap = [ 9 | 'Collapsing roof triggered by a tripwire', 10 | 'Falling cage triggered by a tripwire', 11 | 'Falling net triggered by a tripwire', 12 | 'Floor has been greased', 13 | 'Floor is covered in ball bearings', 14 | 'Floor is covered in caltrops', 15 | 'Hidden pit trap constructed from material identical to the floor', 16 | 'Hidden spiked pit trap constructed from material identical to the floor', 17 | 'Hunting traps are scattered throughout the floor', 18 | 'Locking spiked pit trap constructed from material identical to the floor', 19 | 'Locking spring-loaded pit trap constructed from material identical to the floor', 20 | 'Poison darts triggered by a pressure plate', 21 | 'Poison needle triggered by an object in the room', 22 | 'Razorblades triggered by an object in the room', 23 | 'Rolling boulder triggered by a pressure plate', 24 | 'Simple pit trap covered and camouflaged', 25 | 'Simple spiked pit trap covered and camouflaged', 26 | ]; 27 | 28 | export default trap; 29 | -------------------------------------------------------------------------------- /app/room/vegetation.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { capitalize, sentenceList, toss } from '../utility/tools.js'; 4 | import { roll, rollPercentile, rollArrayItem } from '../utility/roll.js'; 5 | 6 | // -- Type Imports ------------------------------------------------------------- 7 | 8 | /** @typedef {import('./generate.js').RandomizedRoomConfig} RandomizedRoomConfig */ 9 | 10 | // -- Type Imports ------------------------------------------------------------- 11 | 12 | /** @typedef {typeof vegetationType[number]} VegetationType */ 13 | 14 | // -- Config ------------------------------------------------------------------- 15 | 16 | /** Percentile chance to include a vegetation description with a room. */ 17 | const vegetationChance = 60; 18 | 19 | /** Maximum number of vegetation for a room. */ 20 | const maxVegetation = 3; 21 | 22 | export const vegetationType = Object.freeze(/** @type {const} */ ([ 23 | 'ferns', 24 | 'flowers', 25 | 'grass', 26 | 'moss', 27 | 'mushrooms', 28 | 'roots', 29 | 'vines', 30 | ])); 31 | 32 | // -- Private Functions -------------------------------------------------------- 33 | 34 | /** 35 | * Get vegetation description 36 | * 37 | * @private 38 | * @throws 39 | * 40 | * @param {VegetationType} type 41 | * @param {object} [options] 42 | * @param {boolean} [options.variation = true | false] 43 | * 44 | * @returns {string} 45 | */ 46 | function getDescription(type, { variation = Boolean(roll()) } = {}) { 47 | switch (type) { 48 | case 'ferns': 49 | case 'flowers': { 50 | let description = variation 51 | ? 'are somehow growing here' 52 | : 'are growing from cracks in the walls'; 53 | 54 | return `${type} ${description}`; 55 | } 56 | 57 | case 'grass': 58 | return variation 59 | ? 'grass pokes through cracks in the floor' 60 | : 'patches of grass decorate the ground'; 61 | 62 | case 'moss': 63 | return variation 64 | ? 'moss covers the entire room' 65 | : 'damp moss clings to the walls'; 66 | 67 | case 'mushrooms': 68 | return variation 69 | ? 'glowing mushrooms illuminate your surroundings' 70 | : 'strange mushrooms are scattered about'; 71 | 72 | case 'roots': 73 | return variation 74 | ? 'roots push through the walls and ceiling' 75 | : 'roots disrupt the ground'; 76 | 77 | case 'vines': 78 | return `vines ${variation ? 'cover' : 'cling to'} the walls`; 79 | 80 | default: 81 | toss(`Invalid vegetation type "${type}" in getDescription()`); 82 | } 83 | } 84 | 85 | export { 86 | getDescription as testGetDescription, 87 | }; 88 | 89 | // -- Public Functions --------------------------------------------------------- 90 | 91 | /** 92 | * Generate vegetation description 93 | * 94 | * TODO extract noop to caller 95 | * TODO hook up room settings 96 | * 97 | * @param {RandomizedRoomConfig} config 98 | * @param {object} [options] 99 | * @param {number} [options.count = number] 100 | * 101 | * @returns {string} 102 | */ 103 | export function getRoomVegetationDescription(config, { count = roll(1, maxVegetation) } = {}) { 104 | if (!rollPercentile(vegetationChance)) { 105 | return; 106 | } 107 | 108 | if (count < 1 || count > maxVegetation) { 109 | throw new TypeError('Invalid vegetation count'); 110 | } 111 | 112 | let types = new Set(); 113 | 114 | for (let i = 0; i < count; i++) { 115 | types.add(rollArrayItem(vegetationType)); 116 | } 117 | 118 | let roomVegetation = [ ...types ].map((type) => getDescription(type)); 119 | 120 | return capitalize(sentenceList(roomVegetation)); 121 | } 122 | -------------------------------------------------------------------------------- /app/ui/alert.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { toss } from '../utility/tools.js'; 4 | 5 | // -- Config ------------------------------------------------------------------- 6 | 7 | /** 8 | * Toast exit duration. Must match the `animation-duration` CSS on the 9 | * `[data-toast][data-visible="exit"]` styles. 10 | */ 11 | const toastExitDuration = 2000; // ms 12 | 13 | // -- Public Functions --------------------------------------------------------- 14 | 15 | let toastTimeoutID; 16 | 17 | /** 18 | * Returns a toast message as an HTML string. 19 | * 20 | * TODO unit test 21 | * 22 | * @param {HTMLElement} el 23 | * The "toast" Sections element, denoted by `id="toast"` in the main layout. 24 | * 25 | * @param {string} label 26 | * 27 | * @param {{ duration?: number; success?: boolean }} [options] 28 | */ 29 | export const toast = (el, label, { duration = 6000, success = true } = {}) => { 30 | if (!toast) { toss('toast element is required in toast()'); } 31 | if (!label) { toss('label is required in toast()'); } 32 | 33 | clearTimeout(toastTimeoutID); 34 | 35 | el.innerHTML = label; 36 | el.dataset.visible = ''; 37 | el.dataset.toast = success ? 'success' : 'error'; 38 | 39 | toastTimeoutID = setTimeout(() => { 40 | el.dataset.visible = 'exit'; 41 | 42 | toastTimeoutID = setTimeout(() => { 43 | delete el.dataset.visible; 44 | el.dataset.toast = ''; 45 | }, toastExitDuration); 46 | }, duration); 47 | }; 48 | -------------------------------------------------------------------------------- /app/ui/block.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { element } from '../utility/element.js'; 4 | 5 | // -- Types -------------------------------------------------------------------- 6 | 7 | /** @typedef {import('../utility/element.js').Attributes} Attributes */ 8 | 9 | // -- Public Functions --------------------------------------------------------- 10 | 11 | /** 12 | * Returns an HTML article element string. 13 | * 14 | * @param {string} content 15 | * @param {Attributes} [attributes] 16 | * 17 | * @returns {string} 18 | */ 19 | export const article = (content, attributes) => element('article', content, attributes); 20 | 21 | /** 22 | * Returns an HTML div element string. 23 | * 24 | * @param {string} content 25 | * @param {Attributes} [attributes] 26 | * 27 | * @returns {string} 28 | */ 29 | export const div = (content, attributes) => element('div', content, attributes); 30 | 31 | /** 32 | * Returns an HTML fieldset element string. 33 | * 34 | * @param {string} content 35 | * @param {Attributes} [attributes] 36 | * 37 | * @returns {string} 38 | */ 39 | export const fieldset = (content, attributes) => element('fieldset', content, attributes); 40 | 41 | /** 42 | * Returns an HTML header element string. 43 | * 44 | * @param {string} content 45 | * @param {Attributes} [attributes] 46 | * 47 | * @returns {string} 48 | */ 49 | export const header = (content, attributes) => element('header', content, attributes); 50 | 51 | /** 52 | * Returns an HTML section element string. 53 | * 54 | * @param {string} content 55 | * @param {Attributes} [attributes] 56 | * 57 | * @returns {string} 58 | */ 59 | export const section = (content, attributes) => element('section', content, attributes); 60 | -------------------------------------------------------------------------------- /app/ui/button.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { element } from '../utility/element.js'; 4 | import { toss } from '../utility/tools.js'; 5 | 6 | // -- Types -------------------------------------------------------------------- 7 | 8 | /** @typedef {import('../controller/controller.js').Action} Action */ 9 | /** @typedef {import('../utility/element.js').Attributes} Attributes */ 10 | 11 | // -- Config ------------------------------------------------------------------- 12 | 13 | export const infoLabel = '?'; 14 | 15 | /** 16 | * Valid button sizes 17 | */ 18 | const validSizes = new Set([ 'auto', 'large', 'small' ]); 19 | 20 | // -- Public Functions --------------------------------------------------------- 21 | 22 | /** 23 | * Returns an HTML element button string. 24 | * 25 | * @param {string} label 26 | * @param {Action} action 27 | * @param {{ 28 | * active ?: boolean; 29 | * ariaLabel?: string; 30 | * disabled ?: boolean; 31 | * size ?: "auto" | "large" | "small"; 32 | * target ?: string; 33 | * type ?: "button" | "submit"; 34 | * value ?: string; 35 | * }} [options] 36 | * 37 | * @throws 38 | * 39 | * @returns {string} 40 | */ 41 | export function button(label, action, { 42 | active, 43 | ariaLabel, 44 | disabled, 45 | size = 'small', 46 | target, 47 | type = 'button', 48 | value, 49 | } = {}) { 50 | !label && toss('label is required by button()'); 51 | !action && toss('action is required by button()'); 52 | !validSizes.has(size) && toss('Invalid button size'); 53 | 54 | let dataAttrs = { 55 | action, 56 | size, 57 | ...(active && { active }), 58 | ...(target && { target }), 59 | ...(value && { value }), 60 | ...(label === infoLabel && { 'info': '' }), 61 | }; 62 | 63 | /** @type {import('./block.js').Attributes} */ 64 | let attributes = Object.keys(dataAttrs).reduce((attrs, key) => { 65 | attrs[`data-${key}`] = dataAttrs[key]; 66 | return attrs; 67 | }, {}); 68 | 69 | attributes['type'] = type; 70 | 71 | if (ariaLabel) { 72 | attributes['aria-label'] = ariaLabel; 73 | } 74 | 75 | if (disabled) { 76 | // TODO test 77 | attributes.disabled = ''; 78 | } 79 | 80 | return element('button', label, attributes); 81 | } 82 | -------------------------------------------------------------------------------- /app/ui/field.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { element } from '../utility/element.js'; 4 | import { toWords, toss } from '../utility/tools.js'; 5 | 6 | // -- Types -------------------------------------------------------------------- 7 | 8 | /** @typedef {import('../utility/element.js').Attributes} Attributes */ 9 | 10 | // -- Private Functions -------------------------------------------------------- 11 | 12 | /** 13 | * Returns an HTML option element string. 14 | * 15 | * @private 16 | * 17 | * @param {string} value 18 | * @param {string} label 19 | * @param {Attributes} [attributes = {}] 20 | * 21 | * @returns {string} 22 | */ 23 | const option = (value, label, attributes = {}) => element('option', label, { ...attributes, value }); 24 | 25 | // -- Public Functions --------------------------------------------------------- 26 | 27 | /** 28 | * Returns an HTML field label element string. 29 | * 30 | * @param {string} label 31 | * @param {Attributes} [attributes = {}] 32 | * 33 | * @returns {string} 34 | */ 35 | export const fieldLabel = (label, attributes = {}) => element('label', label, attributes); 36 | 37 | /** 38 | * Returns an HTML input element string. 39 | * 40 | * @param {string} name 41 | * @param {Attributes} [attributes] 42 | * 43 | * @throws 44 | * 45 | * @returns {string} 46 | */ 47 | export function input(name, attributes = {}) { 48 | attributes.name && toss('Input `attrs` cannot contain a name'); 49 | 50 | Object.keys(attributes).forEach(key => { 51 | attributes[key] === undefined && delete attributes[key]; 52 | }); 53 | 54 | return element('input', null, { type: 'text', ...attributes, name }); 55 | } 56 | 57 | /** 58 | * Returns an HTML select element string. 59 | * 60 | * @param {string} name 61 | * @param {string[]} values 62 | * @param {string} [selectedValue] 63 | * @param {Attributes} [attributes = {}] 64 | * 65 | * @returns {string} 66 | */ 67 | export function select(name, values, selectedValue, attributes = {}) { 68 | (!values || !values.length) && toss('Select fields require option values'); 69 | 70 | let options = values.map((value) => { 71 | /** @type {Attributes} */ 72 | let attrs = value === selectedValue ? { selected: '' } : {}; 73 | 74 | return option(value, toWords(value), attrs); 75 | }).join(''); 76 | 77 | return element('select', options, { ...attributes, name }); 78 | } 79 | 80 | /** 81 | * Returns an HTML input range type element string. 82 | * 83 | * @param {string} name 84 | * @param {Attributes} [attributes] 85 | * 86 | * @throws 87 | * 88 | * @returns {string} 89 | */ 90 | export function slider(name, attributes = {}) { 91 | let { type, min, max } = attributes; 92 | 93 | type && toss('Slider `attrs` cannot contain a type'); 94 | min && typeof min !== 'number' && toss('Slider `min` must be a number'); 95 | max && typeof max !== 'number' && toss('Slider `max` must be a number'); 96 | min >= max && toss('Slider `min` must be less than `max`'); 97 | 98 | return input(name, { type: 'range', min: 1, max: 100, ...attributes }); 99 | } 100 | -------------------------------------------------------------------------------- /app/ui/footer.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { link } from './link.js'; 4 | import { small, span } from './typography.js'; 5 | import { currentVersion } from '../pages/notes.js'; 6 | 7 | // -- Config -------------------------------------------------------- 8 | 9 | const mysticWaffleUrl = 'https://www.mysticwaffle.com'; 10 | const commentsUrl = mysticWaffleUrl + '/dnd-generator#comments'; 11 | const privacyUrl = 'https://www.mysticwaffle.com/privacy-policy'; 12 | 13 | const copyright = `D&D Generator by ${link('Mystic Waffle', mysticWaffleUrl, { target: '_blank' })}`; 14 | 15 | // -- Private Functions -------------------------------------------------------- 16 | 17 | /** 18 | * Returns a list of HTML items spaced by bullets. 19 | * 20 | * @param {string[]} items 21 | * 22 | * @returns {string} 23 | */ 24 | const spacedItems = (items) => items.join(span('•', { 'data-spacing': 'x-small' })); 25 | 26 | // -- Public Functions --------------------------------------------------------- 27 | 28 | /** 29 | * Content and format for the application footer. 30 | * 31 | * @param {string} testSummary 32 | * 33 | * @returns {string} 34 | */ 35 | export const getFooter = (testSummary) => 36 | small(spacedItems([ `Alpha ${currentVersion}` ])) 37 | + small(copyright) 38 | + small(spacedItems([ 39 | link('Comments', commentsUrl, { target: '_blank' }), 40 | link('Release Notes', '', { 'data-action': 'navigate', href: '/release-notes' }), 41 | link('Privacy Policy', privacyUrl, { target: '_blank' }), 42 | ])); 43 | -------------------------------------------------------------------------------- /app/ui/icon.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | // TODO round corners of icon shapes 4 | 5 | export const dungeonIcon = ''; 6 | export const roomsIcon = ''; 7 | export const itemsIcon = ''; 8 | -------------------------------------------------------------------------------- /app/ui/link.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { element } from '../utility/element.js'; 4 | 5 | // -- Types -------------------------------------------------------------------- 6 | 7 | /** @typedef {import('../utility/element.js').Attributes} Attributes */ 8 | 9 | // -- Public Functions --------------------------------------------------------- 10 | 11 | /** 12 | * Returns an HTML anchor element string. 13 | * 14 | * @param {string} label 15 | * @param {string} [href] 16 | * @param {Attributes} [attributes] 17 | * 18 | * @returns {string} 19 | */ 20 | export const link = (label, href, attributes) => element('a', label, { 21 | ...attributes, 22 | ...(href && { href }), 23 | }); 24 | -------------------------------------------------------------------------------- /app/ui/list.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { element } from '../utility/element.js'; 4 | import { toss } from '../utility/tools.js'; 5 | 6 | // -- Types -------------------------------------------------------------------- 7 | 8 | /** @typedef {import('../utility/element.js').Attributes} Attributes */ 9 | 10 | // -- Public Functions --------------------------------------------------------- 11 | 12 | /** 13 | * Returns an HTML unordered list element strings with each item wrapped in an 14 | * HTML list item element string. 15 | * 16 | * @param {string[]} items 17 | * @param {Attributes} [attributes] 18 | * 19 | * @throws 20 | * 21 | * @returns {string} 22 | */ 23 | export function list(items, attributes) { 24 | (!items || !items.length) && toss('Items are required for lists'); 25 | 26 | let content = items.map((item) => element('li', item)).join(''); 27 | 28 | return element('ul', content, attributes); 29 | } 30 | -------------------------------------------------------------------------------- /app/ui/nav.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { capitalize, toss } from '../utility/tools.js'; 4 | import { generators } from '../controller/controller.js'; 5 | import { link } from './link.js'; 6 | 7 | // -- Types -------------------------------------------------------------------- 8 | 9 | /** @typedef {import('../controller/controller.js').Generator} Generator */ 10 | /** @typedef {import('../controller/controller.js').Sections} Sections */ 11 | 12 | 13 | // -- Public Functions --------------------------------------------------------- 14 | 15 | /** 16 | * Returns the main navigation as an HTML element string. 17 | * 18 | * @param {Generator} [activeGenerator] 19 | * 20 | * @returns {string} 21 | */ 22 | export const getNav = (activeGenerator) => Object.entries(generators) 23 | .map(([ route, generator ], i) => link(capitalize(generator), route, { 24 | 'data-action': 'navigate', 25 | 'style' : `animation-delay: ${2000 + (500 * i)}ms;`, 26 | ...(activeGenerator === generator ? { 'data-active': '' } : null), 27 | })).join(''); 28 | 29 | /** 30 | * Sets the active navigation target. 31 | * 32 | * @param {HTMLElement} nav 33 | * @param {Generator} [generator] 34 | */ 35 | export function setActiveNavItem(nav, generator) { 36 | [ ...nav.children ].forEach((a) => { 37 | if (!(a instanceof HTMLElement)) { 38 | return; 39 | } 40 | 41 | let href = a.getAttribute('href'); 42 | 43 | if (!href) { 44 | // TODO tests 45 | toss(`Nav item missing href in setActiveNavItem()`); 46 | } 47 | 48 | if (generators[href] === generator) { 49 | a.dataset.active = ''; 50 | return; 51 | } 52 | 53 | delete a.dataset.active; 54 | }); 55 | } 56 | -------------------------------------------------------------------------------- /app/ui/spinner.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { div } from './block.js'; 4 | import { paragraph } from './typography.js'; 5 | 6 | // -- Public Functions --------------------------------------------------------- 7 | 8 | /** 9 | * Returns a loading indicator as an HTML string. 10 | * 11 | * @param {string} [label] 12 | * 13 | * @returns {string} 14 | */ 15 | export const spinner = (label = 'Mumbling incantations...') => div( 16 | paragraph(label), 17 | { 18 | 'data-spinner': '', 19 | } 20 | ); 21 | -------------------------------------------------------------------------------- /app/ui/test/tests.block.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { parseHtml } from '../../utility/element.js'; 4 | import { 5 | article, 6 | div, 7 | fieldset, 8 | header, 9 | section, 10 | } from '../block.js'; 11 | 12 | const blocks = { 13 | article, 14 | div, 15 | fieldset, 16 | header, 17 | section, 18 | }; 19 | 20 | /** 21 | * @param {import('../../unit/state.js').Utility} utility 22 | */ 23 | export default ({ assert, describe, it }) => { 24 | 25 | // -- Public Functions ----------------------------------------------------- 26 | 27 | Object.entries(blocks).forEach(([ tag, func ]) => { 28 | describe(`${tag}()`, () => { 29 | const body = parseHtml(func('Gandalf', { 30 | 'aria-label' : 'Watch out!', 31 | 'data-action': 'fireball', 32 | })); 33 | 34 | const element = body.children.item(0); 35 | 36 | it('returns an single element', () => { 37 | assert(body.children.length).equals(1); 38 | }); 39 | 40 | it(`returns an html ${tag} element`, () => { 41 | assert(element.tagName).equals(tag.toUpperCase()); 42 | }); 43 | 44 | it('contains the given label', () => { 45 | assert(element.textContent).equals('Gandalf'); 46 | }); 47 | 48 | it('has the given attributes', () => { 49 | assert(element).hasAttributes({ 50 | 'aria-label' : 'Watch out!', 51 | 'data-action': 'fireball', 52 | }); 53 | }); 54 | }); 55 | }); 56 | 57 | }; 58 | -------------------------------------------------------------------------------- /app/ui/test/tests.button.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { button, infoLabel } from '../button.js'; 4 | import { parseHtml } from '../../utility/element.js'; 5 | 6 | /** 7 | * @param {import('../../unit/state.js').Utility} utility 8 | */ 9 | export default ({ assert, describe, it }) => { 10 | 11 | // -- Public Functions ----------------------------------------------------- 12 | 13 | describe('button()', () => { 14 | const body = parseHtml(button('click me', 'navigate')); 15 | const element = body.children.item(0); 16 | 17 | it('returns an single element', () => { 18 | assert(body.children.length).equals(1); 19 | }); 20 | 21 | it('returns an HTML button element', () => { 22 | assert(element.tagName).equals('BUTTON'); 23 | }); 24 | 25 | it('has a data-size="small" attribute by default', () => { 26 | assert(element).hasAttributes({ 'data-size': 'small' }); 27 | }); 28 | 29 | it('has a type="button" attribute by default', () => { 30 | assert(element).hasAttributes({ 'type': 'button' }); 31 | }); 32 | 33 | it('has a data-action attribute by default', () => { 34 | assert(element).hasAttributes({ 'data-action': 'navigate' }); 35 | }); 36 | 37 | it('includes the label', () => { 38 | assert(element.textContent).equals('click me'); 39 | }); 40 | 41 | describe('given no label', () => { 42 | it('throws', () => { 43 | // @ts-expect-error 44 | assert(() => button()).throws('label is required by button()'); 45 | }); 46 | }); 47 | 48 | describe('given no action', () => { 49 | it('throws', () => { 50 | // @ts-expect-error 51 | assert(() => button('click me')).throws('action is required by button()'); 52 | }); 53 | }); 54 | 55 | describe('given a label that matches the infoLabel', () => { 56 | it('contains a data-info attribute', () => { 57 | assert(parseHtml(button(infoLabel, 'navigate')).children.item(0)) 58 | .hasAttributes({ 'data-info': '' }); 59 | }); 60 | }); 61 | 62 | describe('given a truthy active option', () => { 63 | it('contains a data-active="true" attribute', () => { 64 | const html = button('Magic missile', 'toggle', { active: true }); 65 | 66 | assert(parseHtml(html).children.item(0)) 67 | .hasAttributes({ 'data-active': 'true' }); 68 | }); 69 | }); 70 | 71 | describe('given an invalid size option', () => { 72 | it('throws', () => { 73 | // @ts-expect-error 74 | assert(() => button('Magic missile', 'toggle', { size: 'invalid-size' })) 75 | .throws('Invalid button size'); 76 | }); 77 | }); 78 | 79 | describe('given a target option', () => { 80 | it('contains a data-target attribute with the target value', () => { 81 | const html = button('Magic missile', 'toggle', { target: 'blueberries' }); 82 | 83 | assert(parseHtml(html).children.item(0)) 84 | .hasAttributes({ 'data-target': 'blueberries' }); 85 | }); 86 | }); 87 | 88 | describe('given a value option', () => { 89 | it('contains a data-value attribute with the value', () => { 90 | const html = button('Magic missile', 'toggle', { value: 'honeybees' }); 91 | 92 | assert(parseHtml(html).children.item(0)) 93 | .hasAttributes({ 'data-value': 'honeybees' }); 94 | }); 95 | }); 96 | 97 | describe('given a type option', () => { 98 | it('contains a type attribute with the type', () => { 99 | const html = button('Magic missile', 'toggle', { type: 'submit' }); 100 | 101 | assert(parseHtml(html).children.item(0)) 102 | .hasAttributes({ type: 'submit' }); 103 | }); 104 | }); 105 | 106 | describe('given an ariaLabel option', () => { 107 | it('contains an aria-label attribute with the given value', () => { 108 | const html = button('Magic missile', 'toggle', { ariaLabel: 'Magic button' }); 109 | 110 | assert(parseHtml(html).children.item(0)) 111 | .hasAttributes({ 'aria-label': 'Magic button' }); 112 | }); 113 | }); 114 | }); 115 | 116 | }; 117 | -------------------------------------------------------------------------------- /app/ui/test/tests.footer.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { parseHtml } from '../../utility/element.js'; 4 | import { getFooter } from '../footer.js'; 5 | 6 | /** 7 | * @param {import('../../unit/state.js').Utility} utility 8 | */ 9 | export default ({ assert, describe, it }) => { 10 | 11 | // -- Public Functions ----------------------------------------------------- 12 | 13 | describe('getFooter()', () => { 14 | const body = parseHtml(getFooter('Fake test summary')); 15 | 16 | it('returns valid HTML', () => { 17 | assert(Boolean(body)).isTrue(); 18 | }); 19 | 20 | it('contains the test summary', () => { 21 | assert(body.textContent).stringIncludes('Fake test summary'); 22 | }); 23 | 24 | it('contains attribution', () => { 25 | assert(body.textContent).stringIncludes('D&D Generator by Mystic Waffle'); 26 | }); 27 | 28 | it('contains footer links', () => { 29 | const expectLinks = [ 30 | 'Comments', 31 | 'Mystic Waffle', 32 | 'Privacy Policy', 33 | 'Release Notes', 34 | ]; 35 | 36 | body.querySelectorAll('a').forEach((link) => { 37 | assert(link.textContent).isInArray(expectLinks); 38 | }); 39 | }); 40 | }); 41 | 42 | }; 43 | -------------------------------------------------------------------------------- /app/ui/test/tests.link.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { parseHtml } from '../../utility/element.js'; 4 | import { link } from '../link.js'; 5 | 6 | /** 7 | * @param {import('../../unit/state.js').Utility} utility 8 | */ 9 | export default ({ assert, describe, it }) => { 10 | 11 | // -- Public Functions ----------------------------------------------------- 12 | 13 | describe('link()', () => { 14 | const body = parseHtml(link('Home')); 15 | const element = body.children.item(0); 16 | 17 | it('returns an single element', () => { 18 | assert(body.children.length).equals(1); 19 | }); 20 | 21 | it('returns an HTML anchor element', () => { 22 | assert(element).isElementTag('a'); 23 | }); 24 | 25 | it('contains the given label', () => { 26 | assert(element).hasTextContent('Home'); 27 | }); 28 | 29 | describe('given no href', () => { 30 | it('should not contain an href', () => { 31 | assert(link('Home')).stringExcludes('href'); 32 | }); 33 | }); 34 | 35 | describe('given an href', () => { 36 | it('has the given href attribute', () => { 37 | const html = link('Home', 'https://www.mysticwaffle.com'); 38 | 39 | assert(parseHtml(html).children.item(0)) 40 | .hasAttributes({ href: 'https://www.mysticwaffle.com' }); 41 | }); 42 | }); 43 | 44 | describe('given attributes', () => { 45 | it('has the given attributes', () => { 46 | const html = link('Home', '/', { 47 | 'data-active': true, 48 | 'id': 'home', 49 | }); 50 | 51 | assert(parseHtml(html).children.item(0)) 52 | .hasAttributes({ 'data-active': 'true', id: 'home' }); 53 | }); 54 | }); 55 | }); 56 | 57 | }; 58 | -------------------------------------------------------------------------------- /app/ui/test/tests.list.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { parseHtml } from '../../utility/element.js'; 4 | import { list } from '../list.js'; 5 | 6 | /** 7 | * @param {import('../../unit/state.js').Utility} utility 8 | */ 9 | export default ({ assert, describe, it }) => { 10 | 11 | // -- Public Functions ----------------------------------------------------- 12 | 13 | describe('list()', () => { 14 | describe('given no items', () => { 15 | it('throws', () => { 16 | // @ts-expect-error 17 | assert(() => list()).throws('Items are required for lists'); 18 | }); 19 | }); 20 | 21 | describe('given an empty array', () => { 22 | it('throws', () => { 23 | assert(() => list([])).throws('Items are required for lists'); 24 | }); 25 | }); 26 | 27 | const body = parseHtml(list([ 'Blasted!' ])); 28 | const element = body.children.item(0); 29 | 30 | it('returns an single element', () => { 31 | assert(body.children.length).equals(1); 32 | }); 33 | 34 | it('returns an HTML unordered list element', () => { 35 | assert(element.tagName).equals('UL'); 36 | }); 37 | 38 | describe('given a single list item', () => { 39 | it('includes a single list item with the given content', () => { 40 | const html = list([ 'Pompous Wizards' ], { 'data-type': 'unknown' }); 41 | const items = parseHtml(html).querySelectorAll('li'); 42 | 43 | assert(items.length).equals(1); 44 | assert(items.item(0).textContent).equals('Pompous Wizards'); 45 | }); 46 | }); 47 | 48 | describe('given attributes', () => { 49 | it('has the given attributes', () => { 50 | const html = list([ 'Pompous Wizards' ], { 51 | 'data-spells': 'many', 52 | 'data-type' : 'unknown', 53 | }); 54 | 55 | assert(parseHtml(html).children.item(0)) 56 | .hasAttributes({ 57 | 'data-spells': 'many', 58 | 'data-type' : 'unknown', 59 | }); 60 | }); 61 | }); 62 | 63 | describe('given multiple list items', () => { 64 | it('contains each list item', () => { 65 | const html = list([ 'Beavers', 'Gorillas', 'Guardians' ]); 66 | const items = parseHtml(html).querySelectorAll('li'); 67 | 68 | assert(items.item(0).textContent).equals('Beavers'); 69 | assert(items.item(1).textContent).equals('Gorillas'); 70 | assert(items.item(2).textContent).equals('Guardians'); 71 | }); 72 | }); 73 | }); 74 | 75 | }; 76 | -------------------------------------------------------------------------------- /app/ui/test/tests.nav.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { capitalize } from '../../utility/tools.js'; 4 | import { parseHtml } from '../../utility/element.js'; 5 | import { generators } from '../../controller/controller.js'; 6 | import { 7 | // Public Functions 8 | getNav, 9 | setActiveNavItem, 10 | } from '../nav.js'; 11 | 12 | /** 13 | * @param {import('../../unit/state.js').Utility} utility 14 | */ 15 | export default ({ assert, describe, it }) => { 16 | 17 | // -- Public Functions ----------------------------------------------------- 18 | 19 | describe('getNav()', () => { 20 | const body = parseHtml(getNav('maps')); 21 | 22 | it('returns valid HTML', () => { 23 | assert(Boolean(body)).isTrue(); 24 | }); 25 | 26 | it('contains a nav link for each generator', () => { 27 | Object.entries(generators).forEach(([ route, generator ]) => { 28 | const link = body.querySelector(`[href="${route}"]`); 29 | 30 | assert(link).isElementTag('a'); 31 | assert(link).hasTextContent(capitalize(generator)); 32 | }); 33 | }); 34 | 35 | it('sets the correct active item', () => { 36 | assert(body.querySelector('a[href="/maps"]')) 37 | .hasAttributes({ 'data-active': '' }); 38 | }); 39 | }); 40 | 41 | describe('setActiveNavItem()', () => { 42 | describe('given a container with three nav buttons', () => { 43 | const nav = document.createElement('div'); 44 | nav.innerHTML = ` 45 | Frog 46 | Grog 47 | Nog 48 | `; 49 | 50 | describe('given a generator which is already active', () => { 51 | setActiveNavItem(nav, 'maps'); 52 | 53 | it('remains the active element', () => { 54 | const targetEl = nav.querySelector('[href="/maps"]'); 55 | assert(targetEl).hasAttributes({ 'data-active': '' }); 56 | }); 57 | 58 | it('is the only active element', () => { 59 | assert(nav.querySelectorAll('[data-active]').length).equals(1); 60 | }); 61 | }); 62 | 63 | describe('given a generator that is not the active generator', () => { 64 | setActiveNavItem(nav, 'items'); 65 | 66 | it('sets the target element as the active element', () => { 67 | const targetEl = nav.querySelector('[href="/items"]'); 68 | assert(targetEl).hasAttributes({ 'data-active': '' }); 69 | }); 70 | 71 | it('is the only active element', () => { 72 | assert(nav.querySelectorAll('[data-active]').length).equals(1); 73 | }); 74 | }); 75 | }); 76 | }); 77 | 78 | }; 79 | -------------------------------------------------------------------------------- /app/ui/test/tests.spinner.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { parseHtml } from '../../utility/element.js'; 4 | import { spinner } from '../spinner.js'; 5 | 6 | /** 7 | * @param {import('../../unit/state.js').Utility} utility 8 | */ 9 | export default ({ assert, describe, it }) => { 10 | 11 | // -- Public Functions ----------------------------------------------------- 12 | 13 | describe('spinner()', () => { 14 | const body = parseHtml(spinner()); 15 | const element = body.children.item(0); 16 | 17 | it('returns an single element', () => { 18 | assert(body.children.length).equals(1); 19 | }); 20 | 21 | it('returns an HTML div element with the data-spinner attribute', () => { 22 | assert(element).isElementTag('div'); 23 | assert(element).hasAttributes({ 'data-spinner': '' }); 24 | }); 25 | 26 | it('contains the default label', () => { 27 | assert(element).hasTextContent('Mumbling incantations...'); 28 | }); 29 | 30 | describe('given a custom label href', () => { 31 | it('contains the custom label', () => { 32 | const customSpinner = parseHtml(spinner('Hang tight...')).children.item(0); 33 | assert(customSpinner).hasTextContent('Hang tight...'); 34 | }); 35 | }); 36 | }); 37 | 38 | }; 39 | -------------------------------------------------------------------------------- /app/ui/test/tests.typography.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { parseHtml } from '../../utility/element.js'; 4 | import { 5 | em, 6 | paragraph, 7 | small, 8 | span, 9 | subtitle, 10 | title, 11 | } from '../typography.js'; 12 | 13 | /** @typedef {import('../../utility/element').Attributes} Attributes */ 14 | 15 | const type = { 16 | 'em' : em, 17 | 'h1' : title, 18 | 'h2' : subtitle, 19 | 'p' : paragraph, 20 | 'small' : small, 21 | 'span' : span, 22 | }; 23 | 24 | /** 25 | * @param {import('../../unit/state.js').Utility} utility 26 | */ 27 | export default ({ assert, describe, it }) => { 28 | 29 | // -- Public Functions ----------------------------------------------------- 30 | 31 | Object.entries(type).forEach(([ tag, func ]) => { 32 | describe(`${tag}()`, () => { 33 | const body = parseHtml(func('Expert Keyboardist', { 34 | 'aria-label' : 'Type type type', 35 | 'data-action': 'More typing', 36 | })); 37 | 38 | const element = body.children.item(0); 39 | 40 | it('returns an single element', () => { 41 | assert(body.children.length).equals(1); 42 | }); 43 | 44 | it(`returns an HTML ${tag} element`, () => { 45 | assert(element).isElementTag(tag); 46 | }); 47 | 48 | it('contains the given label', () => { 49 | assert(element).hasTextContent('Expert Keyboardist'); 50 | }); 51 | 52 | it('has the given attributes', () => { 53 | assert(element).hasAttributes({ 54 | 'aria-label' : 'Type type type', 55 | 'data-action': 'More typing', 56 | }); 57 | }); 58 | }); 59 | }); 60 | 61 | }; 62 | -------------------------------------------------------------------------------- /app/ui/toolbar.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { toss } from '../utility/tools.js'; 4 | import { element } from '../utility/element.js'; 5 | import { button } from './button.js'; 6 | 7 | // TODO tests 8 | 9 | // -- Types -------------------------------------------------------------------- 10 | 11 | /** @typedef {import('../controller/controller.js').Generator} Generator */ 12 | /** @typedef {import('../controller/controller.js').Sections} Sections */ 13 | 14 | // -- Config ------------------------------------------------------------------- 15 | 16 | 17 | // -- Private Functions -------------------------------------------------------- 18 | 19 | /** 20 | * Enables the save button. 21 | * 22 | * @param {HTMLElement} toolbar 23 | * 24 | * @returns {HTMLButtonElement | undefined} 25 | */ 26 | function getSaveButton(toolbar) { 27 | let btn = toolbar.querySelector('[data-action="save"]'); 28 | 29 | if (!btn || !(btn instanceof HTMLButtonElement)) { 30 | return; 31 | } 32 | 33 | return btn; 34 | } 35 | 36 | /** 37 | * Returns an HTML toolbar string containing the given items. 38 | * 39 | * @param {string[]} items 40 | * 41 | * @returns {string} 42 | */ 43 | const toolbarItems = (items) => items.map((item) => element('li', item)).join(''); 44 | 45 | // -- Public Functions --------------------------------------------------------- 46 | 47 | /** 48 | * Disables the save button. 49 | * 50 | * @param {HTMLElement} toolbar 51 | */ 52 | export function disableSaveButton(toolbar) { 53 | getSaveButton(toolbar)?.setAttribute('disabled', ''); 54 | } 55 | 56 | /** 57 | * Enables the save button. 58 | * 59 | * @throws 60 | * 61 | * @param {HTMLElement} toolbar 62 | */ 63 | export function enableSaveButton(toolbar) { 64 | let btn = getSaveButton(toolbar); 65 | 66 | if (!btn) { 67 | toss('Unable to find save button in enableSaveButton()'); 68 | } 69 | 70 | btn.removeAttribute('disabled'); 71 | } 72 | 73 | /** 74 | * Returns a toolbar for the current generator. 75 | * 76 | * @param {Generator} [generator] 77 | * 78 | * @returns {string} 79 | */ 80 | export function getToolbar(generator) { 81 | let defaultButtons = [ 82 | button('Save', 'save', { disabled: true }), 83 | ]; 84 | 85 | switch (generator) { 86 | case 'maps': 87 | return toolbarItems(defaultButtons); 88 | 89 | case 'rooms': 90 | return toolbarItems(defaultButtons); 91 | 92 | case 'items': 93 | return toolbarItems(defaultButtons); 94 | 95 | default: 96 | toss('Invalid generator in getToolbar()'); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /app/ui/typography.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { element } from '../utility/element.js'; 4 | 5 | // -- Types -------------------------------------------------------------------- 6 | 7 | /** @typedef {import('../utility/element').Attributes} Attributes */ 8 | 9 | // -- Public Functions --------------------------------------------------------- 10 | 11 | /** 12 | * Returns an HTML emphasis element string. 13 | * 14 | * @param {string} label 15 | * @param {Attributes} [attributes] 16 | * 17 | * @returns {string} 18 | */ 19 | export const em = (label, attributes) => element('em', label, attributes); 20 | 21 | /** 22 | * Returns an HTML paragraph element string. 23 | * 24 | * @param {string} label 25 | * @param {Attributes} [attributes] 26 | * 27 | * @returns {string} 28 | */ 29 | export const paragraph = (label, attributes) => element('p', label, attributes); 30 | 31 | /** 32 | * Returns an HTML small element string. 33 | * 34 | * @param {string} label 35 | * @param {Attributes} [attributes] 36 | * 37 | * @returns {string} 38 | */ 39 | export const small = (label, attributes) => element('small', label, attributes); 40 | 41 | /** 42 | * Returns an HTML span element string. 43 | * 44 | * @param {string} label 45 | * @param {Attributes} [attributes] 46 | * 47 | * @returns {string} 48 | */ 49 | export const span = (label, attributes) => element('span', label, attributes); 50 | 51 | /** 52 | * Returns an HTML subtitle (h3) element string. 53 | * 54 | * @param {string} label 55 | * @param {Attributes} [attributes] 56 | * 57 | * @returns {string} 58 | */ 59 | export const subtitle = (label, attributes) => element('h2', label, attributes); 60 | 61 | /** 62 | * Returns an HTML title (h2) element string. 63 | * 64 | * @param {string} label 65 | * @param {Attributes} [attributes] 66 | * 67 | * @returns {string} 68 | */ 69 | export const title = (label, attributes) => element('h1', label, attributes); 70 | -------------------------------------------------------------------------------- /app/unit.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getTestNav, getOutput } from './unit/output.js'; 4 | import { toss } from './utility/tools.js'; 5 | import { unitState } from './unit/state.js'; 6 | import suite from './unit/suite.js'; 7 | 8 | // -- Config ------------------------------------------------------------------- 9 | 10 | /** 11 | * Container unit test output is rendered to. 12 | */ 13 | const contentContainer = document.getElementById('content'); 14 | 15 | /** 16 | * Container unit test navigation is rendered to. 17 | */ 18 | const navContainer = document.getElementById('nav'); 19 | 20 | /** 21 | * URL params 22 | */ 23 | const urlParams = new URLSearchParams(window.location.search); 24 | 25 | /** 26 | * URL parameter to run a specific test file. 27 | */ 28 | const scope = urlParams.get('scope') || undefined; 29 | 30 | /** 31 | * URL parameter to include verbose output. 32 | */ 33 | const verbose = Boolean(urlParams.get('verbose')); 34 | 35 | // -- Render ------------------------------------------------------------------- 36 | 37 | if (!navContainer) { toss('Cannot find nav element'); } 38 | if (!contentContainer) { toss('Cannot find content element'); } 39 | 40 | navContainer.innerHTML = getTestNav({ scope, verbose }); 41 | contentContainer.innerHTML = getOutput(unitState(), suite, { 42 | scope, 43 | verbose, 44 | onError: console.error, 45 | onSuccess: console.log, 46 | }); 47 | -------------------------------------------------------------------------------- /app/unit/README.md: -------------------------------------------------------------------------------- 1 | # JavaScript D&D Dungeon Generator Unit Tests 2 | 3 | ## Adding tests 4 | 5 | Unit tests are added by creating a test script. Test scripts should be placed 6 | in a `test` directly alongside the JavaScript being tested. 7 | 8 | For example, to add tests for the script `/app/app.js`, create a new test 9 | script: `/app/test/tests.app.js`. 10 | 11 | Test scripts need to import any functions or objects that will be tested and 12 | export a single default function. The exported function will have a `utilities` 13 | object injected as its first parameter. The `utilities` objects contains three 14 | function properties, `describe()`, `it()`, and `assert()`. 15 | 16 | Describe the function, the function's input, and the expected behavior by 17 | nesting `describe()` and `it()` function callbacks. `describe()` and `it()` 18 | take a message string as their first parameter and a callback function as their 19 | second parameter. 20 | 21 | Use the `assert()` function to test if a variable is of the expected type or 22 | value. `assert()` takes a single param of any type and returns an object of 23 | assertion functions for testing the provided variable. 24 | 25 | Example `/app/test/tests.app.js`: 26 | 27 | ```js 28 | import app from '../app.js'; 29 | 30 | export default ({ assert, describe, it }) => { 31 | describe('app()', () => { 32 | describe('given the number `23`', () => { 33 | const result = app(23); 34 | 35 | it('should return a number', () => { 36 | assert(result).isNumber(); 37 | }); 38 | 39 | it('should return the number `32`', () => { 40 | assert(result).equals(32); 41 | }); 42 | }); 43 | }); 44 | }); 45 | ``` 46 | 47 | ## Assertions 48 | 49 | Assertion functions, such as `equals()`, return the object of assertion 50 | functions allowing multiple assertions to be chained together. 51 | 52 | ```js 53 | assert(result).isNumber().equals(32); 54 | ``` 55 | 56 | The following assertion functions are available on the assertions object. 57 | 58 | ```js 59 | assert(value).equals(); 60 | assert(value).equalsArray(); 61 | assert(value).isArray(); 62 | assert(value).isBoolean(); 63 | assert(value).isFalse(); 64 | assert(value).isFunction(); 65 | assert(value).isNull(); 66 | assert(value).isNumber(); 67 | assert(value).isObject(); 68 | assert(value).isString(); 69 | assert(value).isTrue(); 70 | assert(value).isUndefined(); 71 | assert(value).stringIncludes(); 72 | assert(value).stringExcludes(); 73 | assert(value).throws(); 74 | ``` 75 | 76 | ## Including tests in the test suite 77 | 78 | Test functions should be imported into `/unit/suite.js` and included in the 79 | test suite object, keyed by their full path. 80 | 81 | In `/unit/suite.js`: 82 | 83 | ```js 84 | import app from '../app/test/tests.app.js'; 85 | 86 | export default { 87 | '/app/test/tests.app.js': app, 88 | // ... other tests 89 | }; 90 | ``` 91 | 92 | ## Running tets 93 | 94 | All tests can be run by visiting 95 | [unit.html](https://apps.mysticwaffle.com/dnd-dungeon-generator/unit.html) in a 96 | web browser. 97 | 98 | Verbose output can be shown by adding a 99 | [unit.html?verbose=true](https://apps.mysticwaffle.com/dnd-dungeon-generator/unit.html?verbose=true) 100 | URL param. 101 | 102 | Individual test files can be run by adding a scope to the URL params, for example: 103 | [unit.html?scope=/app/utility/test/tests.roll.js](https://apps.mysticwaffle.com/dnd-dungeon-generator/unit.html?scope=/app/utility/test/tests.roll.js) 104 | 105 | To run scoped tests they must be included in the test suite object defined in 106 | `/unit/suite.js`. 107 | -------------------------------------------------------------------------------- /app/unit/run.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getErrorMessage } from '../utility/tools.js'; 4 | 5 | // -- Type Imports ------------------------------------------------------------- 6 | 7 | /** @typedef {import('./state.js').Utility} Utility */ 8 | /** @typedef {import('./state').State} State */ 9 | /** @typedef {import('./state').Summary} Summary */ 10 | 11 | // -- Public Functions --------------------------------------------------------- 12 | 13 | /** 14 | * Run suite 15 | * 16 | * @param {State} state 17 | * @param {{ [path: string]: (Utility) => void }} suite 18 | * @param {string} [scope] 19 | * 20 | * @returns {Summary | undefined} 21 | */ 22 | export default function ({ getSummary, onError, runUnits }, suite, scope) { 23 | if (!suite || typeof suite !== 'object') { 24 | onError('Invalid test suite'); 25 | return; 26 | } 27 | 28 | let entries = Object.entries(suite); 29 | 30 | if (!entries.length) { 31 | onError('Empty test suite'); 32 | return; 33 | } 34 | 35 | let { 36 | [scope || '']: scopedTest, 37 | } = suite; 38 | 39 | if (scope && !scopedTest) { 40 | onError(`Invalid test scope: ${scope}`); 41 | return; 42 | } 43 | 44 | if (scope && scopedTest) { 45 | entries = [ [ scope, scopedTest ] ]; 46 | } 47 | 48 | entries.forEach(([ path, tests ]) => { 49 | if (typeof tests !== 'function') { 50 | onError(`Invalid test function: ${path}`); 51 | return; 52 | } 53 | 54 | try { 55 | runUnits(path, tests); 56 | } catch (error) { 57 | onError(getErrorMessage(error)); 58 | } 59 | }); 60 | 61 | return getSummary(); 62 | } 63 | -------------------------------------------------------------------------------- /app/unit/state.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import * as assertFunctions from './assert.js'; 4 | import { getErrorMessage } from '../utility/tools.js'; 5 | import { getResultMessage } from './output.js'; 6 | 7 | // -- Types -------------------------------------------------------------------- 8 | 9 | /** @typedef {import('./assert.js').Result} Result */ 10 | 11 | /** @typedef {(value?: any) => Assertions} Assertion */ 12 | 13 | /** 14 | * @typedef {object} Assertions 15 | * 16 | * @prop {Assertion} equals 17 | * @prop {Assertion} equalsArray 18 | * @prop {Assertion} equalsObject 19 | * @prop {Assertion} excludesAttributes 20 | * @prop {Assertion} hasAttributes 21 | * @prop {Assertion} hasTextContent 22 | * @prop {Assertion} isArray 23 | * @prop {Assertion} isBoolean 24 | * @prop {Assertion} isElementTag 25 | * @prop {Assertion} isFalse 26 | * @prop {Assertion} isFunction 27 | * @prop {Assertion} isInArray 28 | * @prop {Assertion} isNull 29 | * @prop {Assertion} isNumber 30 | * @prop {Assertion} isObject 31 | * @prop {Assertion} isSet 32 | * @prop {Assertion} isString 33 | * @prop {Assertion} isTrue 34 | * @prop {Assertion} isUndefined 35 | * @prop {Assertion} stringExcludes 36 | * @prop {Assertion} stringIncludes 37 | * @prop {Assertion} throws 38 | */ 39 | 40 | /** @typedef {{ scope: Scope; msg: string }} CurrentScope */ 41 | 42 | /** 43 | * @typedef {object} Entry 44 | * 45 | * @prop {string} msg 46 | * @prop {boolean} isOk 47 | */ 48 | 49 | /** @typedef {"assert()" | "describe()" | "it()" | "default()"} Scope */ 50 | 51 | /** 52 | * @typedef {object} State 53 | * 54 | * @prop {() => Summary} getSummary 55 | * @prop {(error: string) => void} onError 56 | * @prop {(path: string, tests: (utility: Utility) => void) => void} runUnits 57 | */ 58 | 59 | /** 60 | * @typedef {object} Summary 61 | * 62 | * @prop {number} assertions 63 | * @prop {number} errors 64 | * @prop {number} failures 65 | * @prop {Result[]} results 66 | */ 67 | 68 | /** 69 | * @typedef {object} Utility 70 | * 71 | * @prop {(value: any) => Assertions} assert 72 | * @prop {(msg: string, callback: () => void) => void} describe 73 | * @prop {(msg: string, callback: () => void) => void} it 74 | */ 75 | 76 | // -- Config ------------------------------------------------------------------- 77 | 78 | /** @type {{ [key: string]: Scope }} */ 79 | const scope = { 80 | assert : 'assert()', 81 | describe: 'describe()', 82 | it : 'it()', 83 | suite : 'default()', 84 | }; 85 | 86 | // -- Public Functions --------------------------------------------------------- 87 | 88 | /** 89 | * Creates a closure containing unit test state: assertions, errors, and 90 | * failures. Returns an object of unit test operations. 91 | * 92 | * @returns {State} 93 | */ 94 | export function unitState() { 95 | 96 | /** 97 | * Assertions 98 | * 99 | * @type {number} 100 | */ 101 | let assertions = 0; 102 | 103 | /** 104 | * Current 105 | * 106 | * @type {CurrentScope[]} 107 | */ 108 | let current = []; 109 | 110 | /** 111 | * Errors 112 | * 113 | * @type {number} 114 | */ 115 | let errors = 0; 116 | 117 | /** 118 | * Failures 119 | * 120 | * @type {number} 121 | */ 122 | let failures = 0; 123 | 124 | /** 125 | * Results 126 | * 127 | * @type {Result[]} 128 | */ 129 | let results = []; 130 | 131 | /** 132 | * Check scope 133 | * 134 | * @param {string} nextScope 135 | * @param {string[]} allowed 136 | * 137 | * @throws 138 | */ 139 | const checkScope = (nextScope, allowed) => { 140 | let currentEntry = current[current.length - 1]; 141 | let currentScope = currentEntry.scope; 142 | 143 | if (!allowed.includes(currentScope)) { 144 | throw new TypeError(`${nextScope} must be called inside of ${allowed.join(' or ')}`); 145 | } 146 | }; 147 | 148 | /** 149 | * Describe 150 | * 151 | * @param {string} msg 152 | * @param {() => void} callback 153 | */ 154 | const describe = (msg, callback) => { 155 | checkScope(scope.describe, [ scope.suite, scope.describe ]); 156 | 157 | current.push({ scope: scope.describe, msg }); 158 | runCallback(callback); 159 | current.pop(); 160 | }; 161 | 162 | /** 163 | * It 164 | * 165 | * @param {string} msg 166 | * @param {() => void} callback 167 | */ 168 | const it = (msg, callback) => { 169 | checkScope(scope.it, [ scope.describe ]); 170 | 171 | current.push({ scope: scope.it, msg }); 172 | runCallback(callback); 173 | current.pop(); 174 | }; 175 | 176 | /** 177 | * Run assertion 178 | * 179 | * @param {*} actual 180 | * @param {*} expected 181 | * @param {(actual: any, expected: any) => Result} assertion 182 | * 183 | * @returns {{ [key: string]: Assertion }} 184 | */ 185 | const runAssertion = (actual, expected, assertion) => { 186 | checkScope(scope.assert, [ scope.it ]); 187 | 188 | let result = assertion(actual, expected); 189 | let { msg, isOk } = result; 190 | 191 | assertions++; 192 | 193 | if (!isOk) { 194 | failures++; 195 | } 196 | 197 | current.push({ 198 | scope: scope.assert, 199 | msg: `${isOk ? 'Pass:' : 'Failure:'} ${msg}`, 200 | }); 201 | 202 | results.push({ 203 | isOk, 204 | msg: getResultMessage(current), 205 | }); 206 | 207 | current.pop(); 208 | 209 | return assert(actual); 210 | }; 211 | 212 | /** 213 | * Assert 214 | * 215 | * @param {any} value 216 | * 217 | * @returns {ReturnType} 218 | */ 219 | const assert = (value) => Object.entries(assertFunctions).reduce((assertObj, [ key, assertion ]) => { 220 | assertObj[key] = (expected) => runAssertion(value, expected, assertion); 221 | return assertObj; 222 | }, {}); 223 | 224 | /** 225 | * Utility 226 | * 227 | * @type {Utility} 228 | */ 229 | const utility = { 230 | assert, 231 | describe, 232 | it, 233 | }; 234 | 235 | /** 236 | * Runs a callback function, it() or describe(), inside a try/catch block, 237 | * adding any errors to the results output. 238 | * 239 | * @param {() => void} callback 240 | */ 241 | const runCallback = (callback) => { 242 | try { 243 | callback(); 244 | } catch (error) { 245 | onError(getErrorMessage(error)); 246 | } 247 | }; 248 | 249 | /** 250 | * Run units 251 | * 252 | * @param {string} path 253 | * @param {(utility: Utility) => void} tests 254 | */ 255 | const runUnits = (path, tests) => { 256 | current.push({ scope: scope.suite, msg: path }); 257 | tests(utility); 258 | current.pop(); 259 | }; 260 | 261 | /** 262 | * Get summary 263 | * 264 | * @returns {Summary} 265 | */ 266 | const getSummary = () => { 267 | return { 268 | assertions, 269 | errors, 270 | failures, 271 | results: [ ...results ], 272 | }; 273 | }; 274 | 275 | /** 276 | * Get summary 277 | * 278 | * @param {string} msg 279 | */ 280 | const onError = (msg) => { 281 | errors++; 282 | results.push({ isOk: false, msg }); 283 | }; 284 | 285 | return { 286 | getSummary, 287 | onError, 288 | runUnits, 289 | }; 290 | } 291 | -------------------------------------------------------------------------------- /app/unit/suite.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import assert from './test/tests.assert.js'; 4 | import output from './test/tests.output.js'; 5 | import run from './test/tests.run.js'; 6 | import state from './test/tests.state.js'; 7 | 8 | import element from '../utility/test/tests.element.js'; 9 | import roll from '../utility/test/tests.roll.js'; 10 | import shape from '../utility/test/tests.shape.js'; 11 | import tools from '../utility/test/tests.tools.js'; 12 | import xhr from '../utility/test/tests.xhr.js'; 13 | 14 | import condition from '../attribute/test/tests.condition.js'; 15 | import quantity from '../attribute/test/tests.quantity.js'; 16 | import rarity from '../attribute/test/tests.rarity.js'; 17 | import size from '../attribute/test/tests.size.js'; 18 | 19 | import controller from '../controller/test/tests.controller.js'; 20 | import formatter from '../controller/test/tests.formatter.js'; 21 | import knobs from '../controller/test/tests.knobs.js'; 22 | 23 | import block from '../ui/test/tests.block.js'; 24 | import button from '../ui/test/tests.button.js'; 25 | import field from '../ui/test/tests.field.js'; 26 | import footer from '../ui/test/tests.footer.js'; 27 | import form from '../ui/test/tests.form.js'; 28 | import link from '../ui/test/tests.link.js'; 29 | import list from '../ui/test/tests.list.js'; 30 | import nav from '../ui/test/tests.nav.js'; 31 | import spinner from '../ui/test/tests.spinner.js'; 32 | import typography from '../ui/test/tests.typography.js'; 33 | 34 | import generateItems from '../item/test/tests.generate.js'; 35 | import item from '../item/test/tests.item.js'; 36 | 37 | import roomDescription from '../room/test/tests.description.js'; 38 | import roomDimensions from '../room/test/tests.dimensions.js'; 39 | import door from '../room/test/tests.door.js'; 40 | import environment from '../room/test/tests.environment.js'; 41 | import feature from '../room/test/tests.feature.js'; 42 | import generateRooms from '../room/test/tests.generate.js'; 43 | import vegetation from '../room/test/tests.vegetation.js'; 44 | 45 | import draw from '../dungeon/test/tests.draw.js'; 46 | import generate from '../dungeon/test/tests.generate.js'; 47 | import grid from '../dungeon/test/tests.grid.js'; 48 | import legend from '../dungeon/test/tests.legend.js'; 49 | import map from '../dungeon/test/tests.map.js'; 50 | 51 | import notes from '../pages/test/tests.notes.js'; 52 | 53 | // -- Config ------------------------------------------------------------------- 54 | 55 | export default { 56 | '/app/unit/test/tests.assert.js': assert, 57 | '/app/unit/test/tests.output.js': output, 58 | '/app/unit/test/tests.run.js' : run, 59 | '/app/unit/test/tests.state.js' : state, 60 | 61 | '/app/utility/test/tests.element.js': element, 62 | '/app/utility/test/tests.roll.js' : roll, 63 | '/app/utility/test/tests.shape.js' : shape, 64 | '/app/utility/test/tests.tools.js' : tools, 65 | '/app/utility/test/tests.xhr.js' : xhr, 66 | 67 | '/app/attribute/test/tests.condition.js': condition, 68 | '/app/attribute/test/tests.quantity.js' : quantity, 69 | '/app/attribute/test/tests.rarity.js' : rarity, 70 | '/app/attribute/test/tests.size.js' : size, 71 | 72 | '/app/controller/test/tests.controller.js': controller, 73 | '/app/controller/test/tests.formatter.js': formatter, 74 | '/app/controller/test/tests.knobs.js': knobs, 75 | 76 | '/app/ui/test/tests.block.js' : block, 77 | '/app/ui/test/tests.button.js' : button, 78 | '/app/ui/test/tests.field.js' : field, 79 | '/app/ui/test/tests.footer.js' : footer, 80 | '/app/ui/test/tests.form.js' : form, 81 | '/app/ui/test/tests.link.js' : link, 82 | '/app/ui/test/tests.list.js' : list, 83 | '/app/ui/test/tests.nav.js' : nav, 84 | '/app/ui/test/tests.spinner.js' : spinner, 85 | '/app/ui/test/tests.typography.js': typography, 86 | 87 | '/app/item/test/tests.generate.js' : generateItems, 88 | '/app/item/test/tests.item.js' : item, 89 | 90 | '/app/room/test/tests.description.js': roomDescription, 91 | '/app/room/test/tests.dimensions.js' : roomDimensions, 92 | '/app/room/test/tests.door.js' : door, 93 | '/app/room/test/tests.environment.js': environment, 94 | '/app/room/test/tests.feature.js' : feature, 95 | '/app/room/test/tests.generate.js' : generateRooms, 96 | '/app/room/test/tests.vegetation.js' : vegetation, 97 | 98 | '/app/dungeon/test/tests.draw.js' : draw, 99 | '/app/dungeon/test/tests.generate.js': generate, 100 | '/app/dungeon/test/tests.grid.js' : grid, 101 | '/app/dungeon/test/tests.legend.js' : legend, 102 | '/app/dungeon/test/tests.map.js' : map, 103 | 104 | '/app/pages/test/tests.notes.js': notes, 105 | }; 106 | -------------------------------------------------------------------------------- /app/unit/test/tests.run.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import run from '../run.js'; 4 | 5 | const noop = () => {}; 6 | 7 | const mockSummary = { 8 | assertions: 0, 9 | errors : 0, 10 | failures : 0, 11 | results : [], 12 | }; 13 | 14 | const mockUnit = { 15 | getSummary: () => mockSummary, 16 | onError : noop, 17 | runUnits : noop, 18 | }; 19 | 20 | /** 21 | * @param {import('../state.js').Utility} utility 22 | */ 23 | export default ({ assert, describe, it }) => { 24 | 25 | // -- Public Functions ----------------------------------------------------- 26 | 27 | describe('given a suite of test functions', () => { 28 | let names = []; 29 | let functions = []; 30 | 31 | const suite = { 32 | '/test/tests.mock1.js': noop, 33 | '/test/tests.mock2.js': noop, 34 | }; 35 | 36 | const runUnits = (name, tests) => { 37 | names.push(name); 38 | functions.push(tests); 39 | tests(); // Noop 40 | }; 41 | 42 | const getSummary = () => mockSummary; 43 | 44 | const results = run({ ...mockUnit, runUnits, getSummary }, suite); 45 | 46 | it('calls runUnits() for each function in the suite', () => { 47 | const keys = Object.keys(suite); 48 | 49 | names.forEach((name, index) => { 50 | assert(name).equals(keys[index]); 51 | }); 52 | }); 53 | 54 | it('injects a tests function into runUnits() for each test file', () => { 55 | functions.forEach((func) => { 56 | assert(func).isFunction(); 57 | }); 58 | }); 59 | 60 | it('calls getSummary() and returns the summary', () => { 61 | assert(results).equalsObject(mockSummary); 62 | }); 63 | }); 64 | 65 | describe('given an undefined test suite', () => { 66 | let onErrorResult; 67 | 68 | const onError = (error) => { onErrorResult = error; }; 69 | 70 | // @ts-expect-error 71 | run({ ...mockUnit, onError }); 72 | 73 | it('calls `onError()` and returns a string that includes "Invalid"', () => { 74 | assert(onErrorResult) 75 | .isString() 76 | .stringIncludes('Invalid'); 77 | }); 78 | }); 79 | 80 | describe('given an invalid test suite', () => { 81 | let onErrorResult; 82 | 83 | const onError = (error) => { onErrorResult = error; }; 84 | 85 | // @ts-expect-error 86 | run({ ...mockUnit, onError }, 'junk'); 87 | 88 | it('calls `onError()` and returns a string that includes "Invalid"', () => { 89 | assert(onErrorResult) 90 | .isString() 91 | .stringIncludes('Invalid'); 92 | }); 93 | }); 94 | 95 | describe('given an empty test suite', () => { 96 | let onErrorResult; 97 | 98 | const onError = (error) => { onErrorResult = error; }; 99 | 100 | run({ ...mockUnit, onError }, {}); 101 | 102 | it('calls onError() and returns a string that includes "Empty"', () => { 103 | assert(onErrorResult) 104 | .isString() 105 | .stringIncludes('Empty'); 106 | }); 107 | }); 108 | 109 | describe('given a scope', () => { 110 | let unitsRun = []; 111 | 112 | const scope = '/some/scope'; 113 | const suite = { 114 | [scope]: noop, 115 | '/some/other/scope': noop, 116 | }; 117 | 118 | const runUnits = (name) => { unitsRun.push(name); }; 119 | 120 | run({ ...mockUnit, runUnits }, suite, scope); 121 | 122 | it('calls runUnits() once for the scoped function', () => { 123 | assert(unitsRun.length).equals(1); 124 | assert(unitsRun[0]).equals(scope); 125 | }); 126 | }); 127 | 128 | describe('given an invalid scope', () => { 129 | let onErrorResult; 130 | 131 | const onError = (error) => { onErrorResult = error; }; 132 | const suite = { '/some/scope': noop }; 133 | const scope = '/invalid/scope'; 134 | 135 | run({ ...mockUnit, onError }, suite, scope); 136 | 137 | it('calls onError() and returns a string that includes "Invalid" and the test scope', () => { 138 | assert(onErrorResult) 139 | .isString() 140 | .stringIncludes('Invalid') 141 | .stringIncludes('/invalid/scope'); 142 | }); 143 | }); 144 | 145 | describe('given an invalid test function', () => { 146 | let onErrorResult; 147 | 148 | const onError = (error) => { onErrorResult = error; }; 149 | const suite = { '/some/scope': undefined }; 150 | 151 | run({ ...mockUnit, onError }, suite); 152 | 153 | it('calls onError() and returns a string that includes "Invalid" and the test scope', () => { 154 | assert(onErrorResult) 155 | .isString() 156 | .stringIncludes('Invalid') 157 | .stringIncludes('/some/scope'); 158 | }); 159 | }); 160 | 161 | describe('given a test function that throws an Error object', () => { 162 | let onErrorResult; 163 | 164 | const onError = (error) => { onErrorResult = error; }; 165 | const runUnits = (_, tests) => { tests(); }; 166 | const suite = { '/some/scope': () => { throw new TypeError('Whoops'); } }; 167 | 168 | run({ ...mockUnit, onError, runUnits }, suite); 169 | 170 | it('calls onError() and returns a string that includes the error', () => { 171 | assert(onErrorResult) 172 | .isString() 173 | .stringIncludes('Error') 174 | .stringIncludes('Whoops'); 175 | }); 176 | }); 177 | 178 | describe('given a test function that throws an error string', () => { 179 | let onErrorResult; 180 | 181 | const onError = (error) => { onErrorResult = error; }; 182 | const runUnits = (_, tests) => { tests(); }; 183 | const suite = { '/some/scope': () => { throw 'Something is wrong'; } }; 184 | 185 | run({ ...mockUnit, onError, runUnits }, suite); 186 | 187 | it('calls onError() and returns the error string', () => { 188 | assert(onErrorResult) 189 | .isString() 190 | .equals('Something is wrong'); 191 | }); 192 | }); 193 | }; 194 | -------------------------------------------------------------------------------- /app/utility/element.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | // TODO move parts to `/ui` 4 | 5 | import { toss } from './tools.js'; 6 | 7 | // -- Types -------------------------------------------------------------------- 8 | 9 | /** @typedef {{ [attribute: string]: string | number | boolean | undefined }} Attributes */ 10 | 11 | // -- Config ------------------------------------------------------------------- 12 | 13 | const domParser = new DOMParser(); 14 | 15 | /** 16 | * Empty elements 17 | * 18 | * @type {readonly string[]} 19 | */ 20 | export const selfClosingElements = Object.freeze([ 21 | // HTML elements 22 | 'area', 23 | 'base', 24 | 'br', 25 | 'col', 26 | 'embed', 27 | 'hr', 28 | 'img', 29 | 'input', 30 | 'link', 31 | 'meta', 32 | 'param', 33 | 'source', 34 | 'track', 35 | 'wbr', 36 | 37 | // SVG elements 38 | 'circle', 39 | 'line', 40 | 'rect', 41 | ]); 42 | 43 | // -- Private Functions -------------------------------------------------------- 44 | 45 | /** 46 | * Create html attributes 47 | * 48 | * @param {Attributes} [attributes] 49 | * An object with HTML attribute names as the keys and attribute values as 50 | * the values. 51 | * 52 | * @returns {string} 53 | */ 54 | function createAttributes(attributes = {}) { 55 | return Object.keys(attributes).map((key) => { 56 | return ` ${key}="${attributes[key]}"`; 57 | }).join(''); 58 | } 59 | 60 | export { createAttributes as testCreateAttributes }; 61 | 62 | // -- Public Functions --------------------------------------------------------- 63 | 64 | /** 65 | * Element 66 | * 67 | * @param {string} tag 68 | * @param {string | number} [content] 69 | * @param {Attributes} [attributes] 70 | * 71 | * @returns {string} 72 | */ 73 | export function element(tag, content = '', attributes = {}) { 74 | let elementAttributes = createAttributes(attributes); 75 | 76 | if (selfClosingElements.includes(tag)) { 77 | content && toss('Content is not allowed in self closing elements'); 78 | return `<${tag}${elementAttributes} />`; 79 | } 80 | 81 | return `<${tag}${elementAttributes}>${content}`; 82 | } 83 | 84 | /** 85 | * Parses and returns an HTMLDocument fro the given HTML string, or null if the 86 | * string cannot be parsed. 87 | * 88 | * TODO move to dom.js 89 | * 90 | * @throws 91 | * 92 | * @param {string} string 93 | * 94 | * @returns {HTMLBodyElement} 95 | */ 96 | export function parseHtml(string) { 97 | typeof string !== 'string' && toss('A string is required in parseHtml()'); 98 | 99 | let doc = domParser.parseFromString(string, 'text/html'); 100 | let body = doc.querySelector('body'); 101 | 102 | if (!body || (!body.children.length && !body.textContent)) { 103 | toss(`Invalid HTML string "${string}"`); 104 | } 105 | 106 | return body; 107 | 108 | } 109 | /** 110 | * Parses and returns an XMLDocument fro the given SVG string, or null if the 111 | * string cannot be parsed. 112 | * 113 | * TODO move to dom.js 114 | * 115 | * @throws 116 | * 117 | * @param {string} string 118 | * 119 | * @returns {XMLDocument} 120 | */ 121 | export function parseSvg(string) { 122 | typeof string !== 'string' && toss('A string is required in parseSvg()'); 123 | 124 | let doc = domParser.parseFromString(string, 'image/svg+xml'); 125 | let errorNode = doc.querySelector('parsererror'); 126 | 127 | errorNode && toss(`Invalid SVG string "${string}"`); 128 | 129 | return doc; 130 | } 131 | -------------------------------------------------------------------------------- /app/utility/roll.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { toss } from './tools.js'; 4 | 5 | // -- Config ------------------------------------------------------------------- 6 | 7 | const minPercent = 0; 8 | const maxPercent = 100; 9 | 10 | // -- Public Functions --------------------------------------------------------- 11 | 12 | /** 13 | * Returns a probability roll and description in a closure. 14 | * 15 | * @throws 16 | * 17 | * @param {Map} distributionTable 18 | * 19 | * @returns {Readonly<{ 20 | * description: string; 21 | * roll: () => any; 22 | * }>} 23 | */ 24 | export function createProbability(distributionTable) { 25 | !(distributionTable instanceof Map) && toss('distributionTable must be a Map in createProbability()'); 26 | !distributionTable.size && toss('distributionTable Map must have values in createProbability()'); 27 | 28 | distributionTable.forEach((value, key) => { 29 | !Number.isInteger(key) && toss(`distributionTable key "${key}" must be an integer in createProbability()`); 30 | 31 | key < minPercent && toss(`distributionTable key "${key}" must be ${minPercent} or greater in createProbability()`); 32 | key > maxPercent && toss(`distributionTable key "${key}" exceeds ${maxPercent} in createProbability()`); 33 | }); 34 | 35 | let sorted = [ ...distributionTable.keys() ].sort((a, b) => a - b); 36 | 37 | let description = 'Random probability: ' + sorted.reduce((acc, key, index) => { 38 | let prev = sorted[index - 1]; 39 | let start = prev ? (prev + 1) : 1; 40 | let end = key; 41 | 42 | acc.push(`${start}-${end}% ${distributionTable.get(key)}`); 43 | 44 | return acc; 45 | }, []).join(', '); 46 | 47 | return Object.freeze({ 48 | description, 49 | roll: () => { 50 | let result = roll(minPercent, maxPercent); 51 | let key = sorted.find((val) => result <= val); 52 | 53 | return distributionTable.get(key); 54 | }, 55 | }); 56 | } 57 | 58 | /** 59 | * Rolls an integer between min and max inclusively. 60 | * 61 | * @throws 62 | * 63 | * @param {number} [min = 0] 64 | * @param {number} [max = 1] 65 | * 66 | * @returns {number} 67 | */ 68 | export function roll(min = 0, max = 1) { 69 | !Number.isInteger(min) && toss('min must be an integer in roll()'); 70 | !Number.isInteger(max) && toss('max must be an integer in roll()'); 71 | 72 | min < 0 && toss('min cannot be negative in roll()'); 73 | min > max && toss('min must less than or equal to max in roll()'); 74 | 75 | return Math.floor(Math.random() * (max - min + 1)) + min; 76 | } 77 | 78 | /** 79 | * Roll array item 80 | * 81 | * @throws 82 | * 83 | * @param {any[] | readonly any[]} array 84 | * 85 | * @returns {*} 86 | */ 87 | export function rollArrayItem(array) { 88 | !Array.isArray(array) && toss('Invalid array in rollArrayItem()'); 89 | !array.length && toss('array must have values in rollArrayItem()'); 90 | 91 | return array[Math.floor(Math.random() * array.length)]; 92 | } 93 | 94 | /** 95 | * Roll percentile 96 | * 97 | * @throws 98 | * 99 | * @param {number} chance 100 | * 101 | * @returns {boolean} 102 | */ 103 | export function rollPercentile(chance) { 104 | !Number.isInteger(chance) && toss('chance must be an integer in rollPercentile()'); 105 | chance < minPercent && toss(`chance must be ${minPercent} or greater in rollPercentile()`); 106 | chance > maxPercent && toss(`chance cannot exceed ${maxPercent} in rollPercentile()`); 107 | 108 | if (chance === 0) { 109 | return false; 110 | } 111 | 112 | return roll(minPercent, maxPercent) <= chance; 113 | } 114 | -------------------------------------------------------------------------------- /app/utility/shape.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { element } from './element.js'; 4 | import { isRequired } from './tools.js'; 5 | 6 | // -- Type Imports ------------------------------------------------------------- 7 | 8 | /** @typedef {import('./element.js').Attributes} Attributes */ 9 | 10 | // -- Types -------------------------------------------------------------------- 11 | 12 | /** 13 | * @typedef PixelCoordinates 14 | * 15 | * @prop {number} x 16 | * @prop {number} y 17 | */ 18 | 19 | /** 20 | * @typedef PixelDimensions 21 | * 22 | * @prop {number} width 23 | * @prop {number} height 24 | */ 25 | 26 | /** @typedef {PixelCoordinates & PixelDimensions} PixelRectangle */ 27 | 28 | /** 29 | * @typedef Circle 30 | * 31 | * @prop {number} cx 32 | * @prop {number} cy 33 | * @prop {number} r 34 | */ 35 | 36 | /** 37 | * @typedef Line 38 | * 39 | * @prop {number} x1 40 | * @prop {number} y1 41 | * @prop {number} x2 42 | * @prop {number} y2 43 | * @prop {string} color 44 | * @prop {number} width 45 | */ 46 | 47 | // -- Config ------------------------------------------------------------------- 48 | 49 | const dashLength = 5; 50 | 51 | const defaultTextColor = '#666666'; 52 | 53 | const pxTextOffset = 2; 54 | 55 | export const defaultFontSize = 14; 56 | 57 | export { 58 | dashLength as testDashLength, 59 | pxTextOffset as testPxTextOffset, 60 | }; 61 | 62 | // -- Public Functions --------------------------------------------------------- 63 | 64 | /** 65 | * Returns an SVG circle element string. 66 | * 67 | * @private 68 | * 69 | * @param {Circle} circle 70 | * @param {object} [options] 71 | * @param {string} [options.fill] 72 | * @param {string} [options.stroke] 73 | * 74 | * @returns {string} 75 | */ 76 | export function drawCircle({ cx, cy, r }, { fill, stroke } = {}) { 77 | isRequired(cx, 'cx is required by drawCircle()'); 78 | isRequired(cy, 'cy is required by drawCircle()'); 79 | isRequired(r, 'r is required by drawCircle()'); 80 | 81 | let attributes = { 82 | cx, 83 | cy, 84 | r, 85 | fill, 86 | 'shape-rendering': 'geometricPrecision', 87 | ...(stroke && { stroke, 'stroke-width': 2 }), 88 | }; 89 | 90 | return element('circle', null, attributes); 91 | } 92 | 93 | /** 94 | * Returns an SVG line element string. 95 | * 96 | * @private 97 | * 98 | * @param {Line} args 99 | * @param {object} [options = {}] 100 | * @param {boolean} [options.dashed] 101 | * 102 | * @returns {string} 103 | */ 104 | export function drawLine({ x1, y1, x2, y2, color, width }, { dashed } = {}) { 105 | isRequired(x1, 'x1 is required by drawLine()'); 106 | isRequired(y1, 'y1 is required by drawLine()'); 107 | isRequired(x2, 'x2 is required by drawLine()'); 108 | isRequired(y2, 'y2 is required by drawLine()'); 109 | isRequired(color, 'color is required by drawLine()'); 110 | isRequired(width, 'width is required by drawLine()'); 111 | 112 | let attributes = { 113 | x1, 114 | y1, 115 | x2, 116 | y2, 117 | stroke: color, 118 | 'shape-rendering': 'crispEdges', 119 | 'stroke-linecap': 'square', 120 | 'stroke-width': width, 121 | ...(dashed && { 'stroke-dasharray': dashLength }), 122 | }; 123 | 124 | return element('line', null, attributes); 125 | } 126 | 127 | /** 128 | * Returns an SVG rectangle element string. 129 | * 130 | * @private 131 | * 132 | * @param {PixelRectangle} rectangle 133 | * @param {Attributes} [attributes] 134 | * 135 | * @returns {string} 136 | */ 137 | export function drawRect({ x, y, width, height }, attributes = {}) { 138 | isRequired(x, 'x is required by drawRect()'); 139 | isRequired(y, 'y is required by drawRect()'); 140 | isRequired(width, 'width is required by drawRect()'); 141 | isRequired(height, 'height is required by drawRect()'); 142 | 143 | return element('rect', null, { x, y, width, height, ...attributes }); 144 | } 145 | 146 | /** 147 | * Returns an SVG text element string. 148 | * 149 | * @private 150 | * 151 | * @param {string | number} text 152 | * @param {PixelCoordinates} coordinates 153 | * @param {object} [options] 154 | * @param {number} [options.fontSize = 14] 155 | * @param {string} [options.fill = '#666666'] 156 | * 157 | * @returns {string} 158 | */ 159 | export function drawText(text, { x, y }, { fontSize = defaultFontSize, fill = defaultTextColor } = {}) { 160 | let attributes = { 161 | x, 162 | y: y + pxTextOffset, 163 | fill, 164 | 'alignment-baseline': 'middle', 165 | 'font-family': 'monospace', 166 | 'font-size': `${fontSize}px`, 167 | 'text-anchor': 'middle', 168 | }; 169 | 170 | return element('text', text, attributes); 171 | } 172 | -------------------------------------------------------------------------------- /app/utility/test/tests.xhr.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { 4 | // Public Functions 5 | request, 6 | } from '../xhr.js'; 7 | 8 | /** 9 | * Mocks an XHR object. I know, I don't want to talk about it. 10 | * 11 | * @param {object} [options] 12 | * @param {object} [options.response] 13 | * @param {number} [options.status = 200] 14 | * 15 | * @returns {{ 16 | * xhr: { 17 | * addEventListener: (event: string, callback: (any) => void) => void; 18 | * open: (method: string, url: string) => void; 19 | * setRequestHeader: (name: string, value: string) => void; 20 | * response: object; 21 | * send: (data: object) => void; 22 | * status: number; 23 | * }, 24 | * simulate: (event: string) => void; 25 | * getMockValues: () => { 26 | * headerName: string; 27 | * headerValue: string; 28 | * requestData: object; 29 | * requestMethod: "GET"|"POST"; 30 | * requestUrl: string; 31 | * } 32 | * }} 33 | */ 34 | function getMockXhr({ response, status = 200 } = {}) { 35 | let loadListener; 36 | 37 | let headerName; 38 | let headerValue; 39 | let requestData; 40 | let requestMethod; 41 | let requestUrl; 42 | 43 | const xhr = { 44 | addEventListener: (event, callback) => { 45 | if (event === 'load') { 46 | loadListener = callback; 47 | } 48 | }, 49 | open: (method, url) => { 50 | requestMethod = method; 51 | requestUrl = url; 52 | }, 53 | setRequestHeader: (name, value) => { 54 | headerName = name; 55 | headerValue = value; 56 | }, 57 | response, 58 | send: (data) => { 59 | requestData = data; 60 | }, 61 | status, 62 | }; 63 | 64 | return { 65 | xhr, 66 | simulate: (event) => { 67 | event === 'load' && loadListener(); 68 | }, 69 | getMockValues: () => ({ 70 | headerName, 71 | headerValue, 72 | requestData, 73 | requestMethod, 74 | requestUrl, 75 | }), 76 | }; 77 | } 78 | 79 | /** 80 | * @param {import('../../unit/state.js').Utility} utility 81 | */ 82 | export default ({ assert, describe, it }) => { 83 | 84 | // -- Public Functions ----------------------------------------------------- 85 | 86 | describe('request()', () => { 87 | describe('request settings', () => { 88 | let { xhr, getMockValues } = getMockXhr(); 89 | 90 | request('/api/fake/endpoint', { 91 | method: 'POST', 92 | // @ts-expect-error 93 | xhr, 94 | }); 95 | 96 | const mockValues = getMockValues(); 97 | 98 | it('sets the method and url', () => { 99 | assert(mockValues.requestMethod).equals('POST'); 100 | assert(mockValues.requestUrl).equals('/api/fake/endpoint'); 101 | }); 102 | 103 | it('sets a JSON header', () => { 104 | assert(mockValues.headerName).equals('Content-Type'); 105 | assert(mockValues.headerValue).equals('application/json'); 106 | }); 107 | }); 108 | 109 | describe('a successful request', () => { 110 | let { xhr, simulate, getMockValues } = getMockXhr({ 111 | response: JSON.stringify({ data: 'mock response' }), 112 | }); 113 | 114 | let response; 115 | 116 | request('/api/fake/endpoint', { 117 | data: { fake: 'sent data' }, 118 | callback: (data) => { response = data; }, 119 | // @ts-expect-error 120 | xhr, 121 | }); 122 | 123 | simulate('load'); 124 | 125 | const { requestData } = getMockValues(); 126 | 127 | it('makes an xhr request with the provided data and parses the response', () => { 128 | assert(requestData).equals('{"fake":"sent data"}'); 129 | assert(response).equalsObject({ 130 | data: 'mock response', 131 | status: 200, 132 | }); 133 | }); 134 | }); 135 | 136 | describe('an invalid JSON response', () => { 137 | let { xhr, simulate } = getMockXhr({ 138 | response: '{ mock: "junk ', 139 | }); 140 | 141 | let response; 142 | 143 | request('/api/fake/endpoint', { 144 | callback: (data) => { response = data; }, 145 | // @ts-expect-error 146 | xhr, 147 | }); 148 | 149 | simulate('load'); 150 | 151 | it('returns an error message', () => { 152 | assert(response).equalsObject({ 153 | error: 'Unable to parse JSON response', 154 | status: 200, 155 | }); 156 | }); 157 | }); 158 | 159 | describe('given no url', () => { 160 | it('throws', () => { 161 | // @ts-expect-error 162 | assert(() => request()).throws('url is required in request()'); 163 | }); 164 | }); 165 | }); 166 | 167 | 168 | }; 169 | -------------------------------------------------------------------------------- /app/utility/tools.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | // -- Types -------------------------------------------------------------------- 4 | 5 | /** @typedef {{ min: number; max: number }} Range */ 6 | 7 | // -- Typography --------------------------------------------------------------- 8 | 9 | /** 10 | * Capitalizes a string. 11 | * 12 | * @param {string} text 13 | * 14 | * @returns {string} 15 | */ 16 | export const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1); 17 | 18 | /** 19 | * Capitalizes each word in a string. 20 | * 21 | * @param {string} words 22 | * 23 | * @returns {string} 24 | */ 25 | export const capitalizeWords = (words) => words.split(' ').map((word) => capitalize(word)).join(' '); 26 | 27 | /** 28 | * Returns the correct indefinite article for the given word. 29 | * 30 | * @param {string} word 31 | * 32 | * @returns {string} 33 | */ 34 | export function indefiniteArticle(word) { 35 | if ('aeiou'.indexOf(word[0].toLowerCase()) >= 0) { 36 | return 'an'; 37 | } 38 | 39 | return 'a'; 40 | } 41 | 42 | /** 43 | * Pluralize 44 | * 45 | * @param {number} count 46 | * @param {string} string 47 | * @param {string} [suffix = 's'] 48 | * 49 | * @returns {string} 50 | */ 51 | export function pluralize(count, string, suffix = 's') { 52 | return `${string}${count !== 1 ? suffix : ''}`; 53 | } 54 | 55 | /** 56 | * Returns a comma separated list, joining the last two words with "and", for 57 | * use in a sentence. 58 | * 59 | * @param {string[]} parts 60 | * 61 | * @returns {string} 62 | */ 63 | export function sentenceList(parts) { 64 | if (parts.length === 0) { 65 | return; 66 | } 67 | 68 | let last = parts.pop(); 69 | 70 | if (parts.length === 0) { 71 | return last; 72 | } 73 | 74 | let comma = parts.length > 1 ? ',' : ''; 75 | 76 | return `${parts.join(', ')}${comma} and ${last}`; 77 | } 78 | 79 | /** 80 | * Converts spaces to dashes. 81 | * 82 | * @param {string} text 83 | * 84 | * @returns {string} 85 | */ 86 | export const toDash = (text) => text.replace(/\s+/g, '-').toLowerCase(); 87 | 88 | /** 89 | * Converts camel case to words. 90 | * 91 | * @param {string} text 92 | * 93 | * @returns {string} 94 | */ 95 | export const toWords = (text) => text.replace(/([A-Z])/g, ' $1').toLowerCase(); 96 | 97 | // -- Numeric ------------------------------------------------------------------ 98 | 99 | /** 100 | * Returns true if the given number is even. 101 | * 102 | * @param {number} num 103 | * 104 | * @returns {boolean} 105 | */ 106 | export const isEven = (num) => num % 2 === 0; 107 | 108 | /** 109 | * Returns true if the given number is odd. 110 | * 111 | * @param {number} num 112 | * 113 | * @returns {boolean} 114 | */ 115 | export const isOdd = (num) => num % 2 !== 0; 116 | 117 | // -- Array -------------------------------------------------------------------- 118 | 119 | /** 120 | * Returns the given array chunked by size. 121 | * 122 | * @param {*[]} array 123 | * @param {number} size 124 | * 125 | * @returns {*[][]} 126 | */ 127 | export const chunk = (array, size) => array.reduce((newArray, item, index) => { 128 | let chunkIndex = Math.floor(index / size); 129 | 130 | if (!newArray[chunkIndex]) { 131 | newArray[chunkIndex] = []; 132 | } 133 | 134 | newArray[chunkIndex].push(item); 135 | 136 | return newArray; 137 | }, []); 138 | 139 | // -- Object ------------------------------------------------------------------- 140 | 141 | /** 142 | * Returns an object containing min & max number ranges for each provided key 143 | * & min value pair. 144 | * 145 | * @param {{ [key: string]: number }} minimums 146 | * An object of minimums values. 147 | * 148 | * @param {number} [maximum = Number.POSITIVE_INFINITY] 149 | * Maximum value for the largest quantity 150 | * 151 | * @returns {{ [key: string]: Range }} 152 | */ 153 | export function createRangeLookup(minimums, maximum = Number.POSITIVE_INFINITY) { 154 | let entries = Object.entries(minimums || {}); 155 | 156 | entries.length < 1 && toss('Invalid minimums object given in createRangeLookup()'); 157 | 158 | return entries.reduce((ranges, quantity, index, lookup) => { 159 | let [ key, min ] = quantity; 160 | 161 | let next = index + 1; 162 | let max = lookup[next] ? lookup[next][1] - 1 : maximum; 163 | 164 | max < min && toss(`Max cannot be less than min in in createRangeLookup() for key "${key}"`); 165 | 166 | ranges[key] = { min, max }; 167 | 168 | return ranges; 169 | }, {}); 170 | } 171 | 172 | // -- Throw -------------------------------------------------------------------- 173 | 174 | /** 175 | * Throws a type error. 176 | * 177 | * @param {string} message 178 | * 179 | * @throws 180 | * 181 | * @returns {never} 182 | */ 183 | export const toss = (message) => { throw new TypeError(message); }; 184 | 185 | /** 186 | * Throws a type error if the given value is undefined. 187 | * 188 | * @param {any} value 189 | * @param {string} message 190 | * 191 | * @throws 192 | */ 193 | export function isRequired(value, message) { 194 | typeof value === 'undefined' && toss(message); 195 | } 196 | 197 | // -- Error Handling ----------------------------------------------------------- 198 | 199 | /** 200 | * Returns an error message 201 | * 202 | * @param {any} error 203 | * 204 | * @returns {string} 205 | */ 206 | export const getErrorMessage = (error) => { 207 | if (typeof error === 'string') { 208 | return error; 209 | } 210 | 211 | if (error?.stack) { 212 | return error.stack.toString(); 213 | } 214 | 215 | if (typeof error === 'object') { 216 | try { 217 | return JSON.stringify(error); 218 | } catch (err) { 219 | return 'Unable to stringify object: ' + err.stack.toString(); 220 | } 221 | } 222 | 223 | return 'Unknown error type: ' + String(error); 224 | }; 225 | -------------------------------------------------------------------------------- /app/utility/xhr.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { isRequired } from './tools.js'; 4 | 5 | /** 6 | * @typedef {{ 7 | * data?: object; 8 | * error?: string; 9 | * status?: number; 10 | * }} Response 11 | */ 12 | 13 | /** 14 | * @typedef {object} RequestOptions 15 | * 16 | * @prop {(Response) => void} [callback] 17 | * @prop {object} [data] 18 | * @prop {"GET" | "POST"} [options.method = "GET"] 19 | * @prop {XMLHttpRequest} [options.xhr = XMLHttpRequest] 20 | */ 21 | 22 | /** 23 | * @typedef {object} RequestParams 24 | * 25 | * @prop {string} url 26 | * @prop {RequestOptions} [options] 27 | */ 28 | 29 | /** @typedef {(url: string, options?: RequestOptions) => void} Request */ 30 | 31 | /** 32 | * Sends an XHR request in JSON format. 33 | * 34 | * @type {Request} 35 | */ 36 | export function request(url, { 37 | callback, 38 | data, 39 | method = 'GET', 40 | xhr = new XMLHttpRequest(), 41 | } = {}) { 42 | isRequired(url, 'url is required in request()'); 43 | 44 | xhr.addEventListener('load', () => { 45 | let response = {}; 46 | 47 | try { 48 | response = JSON.parse(xhr.response); 49 | } catch (error) { 50 | response.error = 'Unable to parse JSON response'; 51 | } 52 | 53 | response.status = xhr.status; 54 | 55 | callback && callback(response); 56 | }); 57 | 58 | xhr.open(method, url, true); 59 | xhr.setRequestHeader('Content-Type', 'application/json'); 60 | 61 | xhr.send(JSON.stringify(data)); 62 | } 63 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GadgetBlaster/JavaScript-DnD-Dungeon-Generator/b3a44cf3d9580f514b26b3b00aa3d050fb1e3fd4/favicon.ico -------------------------------------------------------------------------------- /humans.txt: -------------------------------------------------------------------------------- 1 | D&D Generator at v1.mysticwaffle.com is web application, forged by AJ, a Human Sorcerer, written (mostly) in JavaScript. The app implements a procedural generation algorithm to draw Dungeons & Dragons game maps as SVG graphics using user input. 2 | 3 | The application requires zero 3rd party library dependencies. Functionally is validated on load by a custom built unit testing framework. 4 | 5 | The project at https://github.com/GadgetBlaster/JavaScript-DnD-Dungeon-Generator is no longer being developed. Checkout the complete rebuild of this dungeon generator at dnd.mysticwaffle.com. 6 | 7 | Happy adventuring! 8 | -------------------------------------------------------------------------------- /img/notes/v0.1.0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GadgetBlaster/JavaScript-DnD-Dungeon-Generator/b3a44cf3d9580f514b26b3b00aa3d050fb1e3fd4/img/notes/v0.1.0.jpg -------------------------------------------------------------------------------- /img/notes/v0.10.0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GadgetBlaster/JavaScript-DnD-Dungeon-Generator/b3a44cf3d9580f514b26b3b00aa3d050fb1e3fd4/img/notes/v0.10.0.jpg -------------------------------------------------------------------------------- /img/notes/v0.11.0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GadgetBlaster/JavaScript-DnD-Dungeon-Generator/b3a44cf3d9580f514b26b3b00aa3d050fb1e3fd4/img/notes/v0.11.0.jpg -------------------------------------------------------------------------------- /img/notes/v0.12.0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GadgetBlaster/JavaScript-DnD-Dungeon-Generator/b3a44cf3d9580f514b26b3b00aa3d050fb1e3fd4/img/notes/v0.12.0.jpg -------------------------------------------------------------------------------- /img/notes/v0.2.0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GadgetBlaster/JavaScript-DnD-Dungeon-Generator/b3a44cf3d9580f514b26b3b00aa3d050fb1e3fd4/img/notes/v0.2.0.jpg -------------------------------------------------------------------------------- /img/notes/v0.3.0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GadgetBlaster/JavaScript-DnD-Dungeon-Generator/b3a44cf3d9580f514b26b3b00aa3d050fb1e3fd4/img/notes/v0.3.0.jpg -------------------------------------------------------------------------------- /img/notes/v0.4.0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GadgetBlaster/JavaScript-DnD-Dungeon-Generator/b3a44cf3d9580f514b26b3b00aa3d050fb1e3fd4/img/notes/v0.4.0.jpg -------------------------------------------------------------------------------- /img/notes/v0.5.0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GadgetBlaster/JavaScript-DnD-Dungeon-Generator/b3a44cf3d9580f514b26b3b00aa3d050fb1e3fd4/img/notes/v0.5.0.jpg -------------------------------------------------------------------------------- /img/notes/v0.6.0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GadgetBlaster/JavaScript-DnD-Dungeon-Generator/b3a44cf3d9580f514b26b3b00aa3d050fb1e3fd4/img/notes/v0.6.0.jpg -------------------------------------------------------------------------------- /img/notes/v0.7.0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GadgetBlaster/JavaScript-DnD-Dungeon-Generator/b3a44cf3d9580f514b26b3b00aa3d050fb1e3fd4/img/notes/v0.7.0.jpg -------------------------------------------------------------------------------- /img/notes/v0.8.0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GadgetBlaster/JavaScript-DnD-Dungeon-Generator/b3a44cf3d9580f514b26b3b00aa3d050fb1e3fd4/img/notes/v0.8.0.jpg -------------------------------------------------------------------------------- /img/notes/v0.9.0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GadgetBlaster/JavaScript-DnD-Dungeon-Generator/b3a44cf3d9580f514b26b3b00aa3d050fb1e3fd4/img/notes/v0.9.0.jpg -------------------------------------------------------------------------------- /img/notes/v1.0.0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GadgetBlaster/JavaScript-DnD-Dungeon-Generator/b3a44cf3d9580f514b26b3b00aa3d050fb1e3fd4/img/notes/v1.0.0.jpg -------------------------------------------------------------------------------- /img/notes/v1.1.0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GadgetBlaster/JavaScript-DnD-Dungeon-Generator/b3a44cf3d9580f514b26b3b00aa3d050fb1e3fd4/img/notes/v1.1.0.jpg -------------------------------------------------------------------------------- /img/notes/v1.2.0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GadgetBlaster/JavaScript-DnD-Dungeon-Generator/b3a44cf3d9580f514b26b3b00aa3d050fb1e3fd4/img/notes/v1.2.0.jpg -------------------------------------------------------------------------------- /img/notes/v1.3.0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GadgetBlaster/JavaScript-DnD-Dungeon-Generator/b3a44cf3d9580f514b26b3b00aa3d050fb1e3fd4/img/notes/v1.3.0.jpg -------------------------------------------------------------------------------- /img/notes/v1.4.0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GadgetBlaster/JavaScript-DnD-Dungeon-Generator/b3a44cf3d9580f514b26b3b00aa3d050fb1e3fd4/img/notes/v1.4.0.jpg -------------------------------------------------------------------------------- /img/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GadgetBlaster/JavaScript-DnD-Dungeon-Generator/b3a44cf3d9580f514b26b3b00aa3d050fb1e3fd4/img/screenshot.jpg -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | D&D Generator 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 22 | 23 | 24 | 25 | 26 |
27 |

28 | 29 | D&D Generator 30 | 31 |

32 | 33 | 34 |
35 | 36 | 37 | 38 |
39 |
40 |

Mumbling incantations...

41 |
42 |
43 | 44 | 47 | 48 |
49 | 50 |
51 |
52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | 4 | Sitemap: https://v1.mysticwaffle.com/sitemap.xml 5 | -------------------------------------------------------------------------------- /sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://v1.mysticwaffle.com/ 5 | 2023-03-25 6 | 7 | 8 | https://v1.mysticwaffle.com/maps 9 | 2023-03-25 10 | 11 | 12 | https://v1.mysticwaffle.com/rooms 13 | 2023-03-25 14 | 15 | 16 | https://v1.mysticwaffle.com/items 17 | 2023-03-25 18 | 19 | 20 | https://v1.mysticwaffle.com/release-notes 21 | 22 | 23 | -------------------------------------------------------------------------------- /tmp/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /unit.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | D&D Generator: Unit Tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 |

20 | 21 | D&D Generator 22 | 23 |

24 | 25 | 26 |
27 | 28 |
29 |
30 |

Mumbling incantations...

31 |
32 |
33 | 34 |
35 | 36 | 37 | 38 | 39 | --------------------------------------------------------------------------------