├── .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 | 
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 | 
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}${tag}>`;
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 |
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 |
27 |
28 |
29 |
30 |
Mumbling incantations...
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------