code:
/***********************************************************************************************************************

	story.js

	Copyright © 2013–2021 Thomas Michael Edwards <thomasmedwards@gmail.com>. All rights reserved.
	Use of this source code is governed by a BSD 2-clause "Simplified" License, which may be found in the LICENSE file.

***********************************************************************************************************************/
/* global Alert, Config, Passage, Scripting, StyleWrapper, Util, Wikifier */

var Story = (() => { // eslint-disable-line no-unused-vars, no-var
	'use strict';

	// Map of normal passages.
	const _passages = {};

	// List of init passages.
	const _inits = [];

	// List of script passages.
	const _scripts = [];

	// List of style passages.
	const _styles = [];

	// List of widget passages.
	const _widgets = [];

	// Story title.
	let _title = '';

	// Story IFID.
	let _ifId = '';

	// DOM-compatible ID.
	let _domId = '';

	/*******************************************************************************************************************
		Story Functions.
	*******************************************************************************************************************/
	function storyLoad() {
		if (DEBUG) { console.log('[Story/storyLoad()]'); }

		const validationCodeTags = [
			'init',
			'widget'
		];
		const validationNoCodeTagPassages = [
			'PassageDone',
			'PassageFooter',
			'PassageHeader',
			'PassageReady',
			'StoryAuthor',
			'StoryBanner',
			'StoryCaption',
			'StoryInit',
			'StoryMenu',
			'StoryShare',
			'StorySubtitle'
		];

		function validateStartingPassage(passage) {
			if (passage.tags.includesAny(validationCodeTags)) {
				throw new Error(`starting passage "${passage.title}" contains special tags; invalid: "${passage.tags.filter(tag => validationCodeTags.includes(tag)).sort().join('", "')}"`);
			}
		}

		function validateSpecialPassages(passage, ...tags) {
			if (validationNoCodeTagPassages.includes(passage.title)) {
				throw new Error(`special passage "${passage.title}" contains special tags; invalid: "${tags.sort().join('", "')}"`);
			}

			const codeTags  = [...validationCodeTags];
			const foundTags = [];

			passage.tags.forEach(tag => {
				if (codeTags.includes(tag)) {
					foundTags.push(...codeTags.delete(tag));
				}
			});

			if (foundTags.length > 1) {
				throw new Error(`passage "${passage.title}" contains multiple special tags; invalid: "${foundTags.sort().join('", "')}"`);
			}
		}

		// For Twine 1.
		if (TWINE1) {
			/*
				Additional Twine 1 validation setup.
			*/
			validationCodeTags.unshift('script', 'stylesheet');
			validationNoCodeTagPassages.push('StoryTitle');

			/*
				Set the default starting passage.
			*/
			Config.passages.start = (() => {
				/*
					Handle the Twine 1.4+ Test Play From Here feature.

					WARNING: Do not remove the `String()` wrapper from or change the quote
					style of the `"START_AT"` replacement target.  The former is there to
					keep UglifyJS from pruning the code into oblivion—i.e. minifying the
					code into something broken.  The latter is there because the Twine 1
					pattern that matches it depends upon the double quotes.

				*/
				const testPlay = String("START_AT"); // eslint-disable-line quotes

				if (testPlay !== '') {
					if (DEBUG) { console.log(`\tTest play; starting passage: "${testPlay}"`); }

					Config.debug = true;
					return testPlay;
				}

				// In the absence of a `testPlay` value, return 'Start'.
				return 'Start';
			})();

			/*
				Process the passages, excluding any tagged 'Twine.private' or 'annotation'.
			*/
			jQuery('#store-area')
				.children(':not([tags~="Twine.private"],[tags~="annotation"])')
				.each(function () {
					const $this   = jQuery(this);
					const passage = new Passage($this.attr('tiddler'), this);

					// Special cases.
					if (passage.title === Config.passages.start) {
						validateStartingPassage(passage);
						_passages[passage.title] = passage;
					}
					else if (passage.tags.includes('init')) {
						validateSpecialPassages(passage, 'init');
						_inits.push(passage);
					}
					else if (passage.tags.includes('stylesheet')) {
						validateSpecialPassages(passage, 'stylesheet');
						_styles.push(passage);
					}
					else if (passage.tags.includes('script')) {
						validateSpecialPassages(passage, 'script');
						_scripts.push(passage);
					}
					else if (passage.tags.includes('widget')) {
						validateSpecialPassages(passage, 'widget');
						_widgets.push(passage);
					}

					// All other passages.
					else {
						_passages[passage.title] = passage;
					}
				});

			/*
				Set the story title or throw an exception.
			*/
			if (_passages.hasOwnProperty('StoryTitle')) {
				const buf = document.createDocumentFragment();
				new Wikifier(buf, _passages.StoryTitle.processText().trim());
				_storySetTitle(buf.textContent);
			}
			else {
				throw new Error('cannot find the "StoryTitle" special passage');
			}

			/*
				Set the default saves ID (must be done after the call to `_storySetTitle()`).
			*/
			Config.saves.id = Story.domId;
		}

		// For Twine 2.
		else {
			const $storydata = jQuery('tw-storydata');
			const startNode  = $storydata.attr('startnode') || '';

			/*
				Set the default starting passage.
			*/
			Config.passages.start = null; // no default in Twine 2

			/*
				Process story options.

				NOTE: Currently, the only option of interest is 'debug', so we
				simply use a regular expression to check for it.
			*/
			Config.debug = /\bdebug\b/.test($storydata.attr('options'));

			/*
				Process stylesheet passages.
			*/
			$storydata
				.children('style') // alternatively: '[type="text/twine-css"]' or '#twine-user-stylesheet'
				.each(function (i) {
					_styles.push(new Passage(`tw-user-style-${i}`, this));
				});

			/*
				Process script passages.
			*/
			$storydata
				.children('script') // alternatively: '[type="text/twine-javascript"]' or '#twine-user-script'
				.each(function (i) {
					_scripts.push(new Passage(`tw-user-script-${i}`, this));
				});

			/*
				Process normal passages, excluding any tagged 'Twine.private' or 'annotation'.
			*/
			$storydata
				.children('tw-passagedata:not([tags~="Twine.private"],[tags~="annotation"])')
				.each(function () {
					const $this   = jQuery(this);
					const pid     = $this.attr('pid') || '';
					const passage = new Passage($this.attr('name'), this);

					// Special cases.
					if (pid === startNode && startNode !== '') {
						Config.passages.start = passage.title;
						validateStartingPassage(passage);
						_passages[passage.title] = passage;
					}
					else if (passage.tags.includes('init')) {
						validateSpecialPassages(passage, 'init');
						_inits.push(passage);
					}
					else if (passage.tags.includes('widget')) {
						validateSpecialPassages(passage, 'widget');
						_widgets.push(passage);
					}

					// All other passages.
					else {
						_passages[passage.title] = passage;
					}
				});

			/*
				Get the story IFID.
			*/
			_ifId = $storydata.attr('ifid');

			/*
				Set the story title.

				FIXME: Maybe `$storydata.attr('name')` should be used instead of `'{{STORY_NAME}}'`?
			*/
			// _storySetTitle($storydata.attr('name'));
			_storySetTitle('{{STORY_NAME}}');

			/*
				Set the default saves ID (must be done after the call to `_storySetTitle()`).
			*/
			Config.saves.id = Story.domId;
		}
	}

	function storyInit() {
		if (DEBUG) { console.log('[Story/storyInit()]'); }

		/*
			Add the story styles.
		*/
		(() => {
			const storyStyle = document.createElement('style');

			new StyleWrapper(storyStyle)
				.add(_styles.map(style => style.text.trim()).join('\n'));

			jQuery(storyStyle)
				.appendTo(document.head)
				.attr({
					id   : 'style-story',
					type : 'text/css'
				});
		})();

		/*
			Evaluate the story scripts.
		*/
		for (let i = 0; i < _scripts.length; ++i) {
			try {
				Scripting.evalJavaScript(_scripts[i].text);
			}
			catch (ex) {
				console.error(ex);
				Alert.error(_scripts[i].title, typeof ex === 'object' ? ex.message : ex);
			}
		}

		/*
			Process the story widgets.
		*/
		for (let i = 0; i < _widgets.length; ++i) {
			try {
				Wikifier.wikifyEval(_widgets[i].processText());
			}
			catch (ex) {
				console.error(ex);
				Alert.error(_widgets[i].title, typeof ex === 'object' ? ex.message : ex);
			}
		}
	}

	function _storySetTitle(rawTitle) {
		if (rawTitle == null) { // lazy equality for null
			throw new Error('story title must not be null or undefined');
		}

		const title = Util.unescape(String(rawTitle)).trim();

		if (title === '') { // lazy equality for null
			throw new Error('story title must not be empty or consist solely of whitespace');
		}

		document.title = _title = title;

		// TODO: In v3 the `_domId` should be created from a combination of the
		// `_title` slug and the IFID, if available, to avoid collisions between
		// stories whose titles generate identical slugs.
		_domId = Util.slugify(_title);

		// [v2] Protect the `_domId` against being an empty string.
		//
		// If `_domId` is empty, attempt a failover.
		if (_domId === '') {
			// If `_ifId` is not empty, then use it.
			if (_ifId !== '') {
				_domId = _ifId;
			}

			// Elsewise generate a string from the `_title`'s code points (in hexadecimal).
			else {
				for (let i = 0, len = _title.length; i < len; ++i) {
					const { char, start, end } = Util.charAndPosAt(_title, i);
					_domId += char.codePointAt(0).toString(16);
					i += end - start;
				}
			}
		}
	}

	function storyTitle() {
		return _title;
	}

	function storyDomId() {
		return _domId;
	}

	function storyIfId() {
		return _ifId;
	}

	/*******************************************************************************************************************
		Passage Functions.
	*******************************************************************************************************************/
	function passagesAdd(passage) {
		if (!(passage instanceof Passage)) {
			throw new TypeError('Story.add passage parameter must be an instance of Passage');
		}

		const title = passage.title;

		if (!_passages.hasOwnProperty(title)) {
			_passages[title] = passage;
			return true;
		}

		return false;
	}

	function passagesHas(title) {
		let type = typeof title;

		switch (type) {
		// Valid types.
		case 'number':
		case 'string':
			return _passages.hasOwnProperty(String(title));

		// Invalid types.  We do the extra processing just to make a nicer error.
		case 'undefined':
			/* no-op */
			break;

		case 'object':
			type = title === null ? 'null' : 'an object';
			break;

		default: // 'bigint', 'boolean', 'function', 'symbol'
			type = `a ${type}`;
			break;
		}

		throw new TypeError(`Story.has title parameter cannot be ${type}`);
	}

	function passagesGet(title) {
		let type = typeof title;

		switch (type) {
		// Valid types.
		case 'number':
		case 'string':
		/* eslint-disable indent */
			{
				const id = String(title);
				return _passages.hasOwnProperty(id) ? _passages[id] : new Passage(id || '(unknown)');
			}
		/* eslint-enable indent */

		// Invalid types.  We do the extra processing just to make a nicer error.
		case 'undefined':
			/* no-op */
			break;

		case 'object':
			type = title === null ? 'null' : 'an object';
			break;

		default: // 'bigint', 'boolean', 'function', 'symbol'
			type = `a ${type}`;
			break;
		}

		throw new TypeError(`Story.get title parameter cannot be ${type}`);
	}

	function passagesGetAllInit() {
		// NOTE: Return an immutable copy, rather than the internal mutable original.
		return Object.freeze(Array.from(_inits));
	}

	function passagesGetAllRegular() {
		// NOTE: Return an immutable copy, rather than the internal mutable original.
		return Object.freeze(Object.assign({}, _passages));
	}

	function passagesGetAllScript() {
		// NOTE: Return an immutable copy, rather than the internal mutable original.
		return Object.freeze(Array.from(_scripts));
	}

	function passagesGetAllStylesheet() {
		// NOTE: Return an immutable copy, rather than the internal mutable original.
		return Object.freeze(Array.from(_styles));
	}

	function passagesGetAllWidget() {
		// NOTE: Return an immutable copy, rather than the internal mutable original.
		return Object.freeze(Array.from(_widgets));
	}

	function passagesLookup(key, value  /* legacy */, sortKey = 'title'/* /legacy */) {
		const results = [];

		Object.keys(_passages).forEach(name => {
			const passage = _passages[name];

			// Objects (sans `null`).
			if (typeof passage[key] === 'object' && passage[key] !== null) {
				// The only object type currently supported is `Array`, since the
				// non-method `Passage` object properties currently yield only either
				// primitives or arrays.
				if (passage[key] instanceof Array && passage[key].some(m => Util.sameValueZero(m, value))) {
					results.push(passage);
				}
			}

			// All other types (incl. `null`).
			else if (Util.sameValueZero(passage[key], value)) {
				results.push(passage);
			}
		});

		// For v3.
		// /* eslint-disable no-nested-ternary */
		// // QUESTION: Do we really need to sort the list?
		// results.sort((a, b) => a.title === b.title ? 0 : a.title < b.title ? -1 : +1);
		// /* eslint-enable no-nested-ternary */

		/* legacy */
		/* eslint-disable eqeqeq, no-nested-ternary, max-len */
		results.sort((a, b) => a[sortKey] == b[sortKey] ? 0 : a[sortKey] < b[sortKey] ? -1 : +1); // lazy equality for null
		/* eslint-enable eqeqeq, no-nested-ternary, max-len */
		/* /legacy */

		return results;
	}

	function passagesLookupWith(predicate /* legacy */, sortKey = 'title'/* /legacy */) {
		if (typeof predicate !== 'function') {
			throw new TypeError('Story.lookupWith predicate parameter must be a function');
		}

		const results = [];

		Object.keys(_passages).forEach(name => {
			const passage = _passages[name];

			if (predicate(passage)) {
				results.push(passage);
			}
		});

		// For v3.
		// /* eslint-disable no-nested-ternary */
		// // QUESTION: Do we really need to sort the list?
		// results.sort((a, b) => a.title === b.title ? 0 : a.title < b.title ? -1 : +1);
		// /* eslint-enable no-nested-ternary */

		/* legacy */
		/* eslint-disable eqeqeq, no-nested-ternary, max-len */
		results.sort((a, b) => a[sortKey] == b[sortKey] ? 0 : a[sortKey] < b[sortKey] ? -1 : +1); // lazy equality for null
		/* eslint-enable eqeqeq, no-nested-ternary, max-len */
		/* /legacy */

		return results;
	}

	/*******************************************************************************************************************
		Module Exports.
	*******************************************************************************************************************/
	return Object.freeze(Object.defineProperties({}, {
		// Story Functions.
		load  : { value : storyLoad },
		init  : { value : storyInit },
		title : { get : storyTitle },
		domId : { get : storyDomId },
		ifId  : { get : storyIfId },

		// Passage Functions.
		add              : { value : passagesAdd },
		has              : { value : passagesHas },
		get              : { value : passagesGet },
		getAllInit       : { value : passagesGetAllInit },
		getAllRegular    : { value : passagesGetAllRegular },
		getAllScript     : { value : passagesGetAllScript },
		getAllStylesheet : { value : passagesGetAllStylesheet },
		getAllWidget     : { value : passagesGetAllWidget },
		lookup           : { value : passagesLookup },
		lookupWith       : { value : passagesLookupWith }
	}));
})();


code:
/* dumb predictable rng algorithm that i came up with as i was falling asleep yesterday */
/* copyleft anomajou, 2024 */
/* also, you owe author a beer now */

class PRNG {
	constructor(seed, pull) {
		// primes are a prime source of entropy in our generator. pun intended. intend your puns, people!
		this.primes = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29];
		// limiter makes sure that after multiplying all parts of a randomizer at their maximum theoretical values, we
		// are still below Number.MAX_SAFE_INTEGER, for better granularity.
		// incidentally, it ends up being 17623651, a prime number, which is perfect for our use
		this.limiter = Math.floor(Math.sqrt(Number.MAX_SAFE_INTEGER / this.primes[this.primes.length - 1]));
		// factor needs to consistently overflow the limiter in an inconsistent way when multiplied by integers in
		// increments of 1, so ideally it has to be another prime in the interval (limiter/2 .. limiter).
		// unlike the limiter, it is much harder to determine programmatically...
		// note: if Number.MAX_SAFE_INTEGER is NOT 9007199254740991 on your system - you need to adjust this factor!
		this.factor = 12345653;
		// pull is the number of times random() was called, and the main input of our generator.
		// it doesn't actually have to be an integer, just incrementable
		this.pull = Number(pull) || 0;

		// seed is the second input for the generator. it has to be below ~1 to stay within MAX_SAFE_INTEGER (although
		// it doesn't hurt much if it's not), and it shouldn't be below ~0.25, or the factor will have trouble
		// overflowing as often as it should. still, other values should be possible, for science
		switch (typeof seed) {
			case 'number': {
				if (seed < 0.25 || seed > 1) console.warn(`recommended seed value is between 0.25 and 1, got ${seed}`);
				this.seed = seed;
				break;
			}
			case 'string': {
				// map character codes onto an interval between 0.25 and 1
				// then at some point it came to me that original string is worth preserving, so let's do that
				this.seed = seed;
				this.seedInt = this.str2int(seed);
				break;
			}
			default: {
				this.seed = (Math.random() * 3 + 1) / 4;
			}
		}
	}

	/**
	 * stupid randomizer function
	 * @param {number} [peek=0] if set, predicts the number that is n steps ahead instead of advancing to the next one
	 * @returns {number (0, 1)} random number between 0 and 1, non-inclusive
	 */
	random(peek = 0) {
		if (!peek) ++this.pull;
		const limiter = this.limiter;
		// prefer seedInt before seed, allowing seed to stay a string
		const seed = this.seedInt || this.seed;
		// make sure seed matters more by affecting effective pull so the chaos can catch up
		// chaos in this case being the ability of tiny variation in seed to result in huge difference in the outcome
		// remember, the real this.pull is meant to represent the real number of times random() was called
		const pull = Math.floor((this.pull + peek + seed * 4327) % limiter) + 1;
		// get some extra entropy by alternating primes
		const extra = this.primes[pull % (this.primes.length - 1)];

		return pull * seed * extra * this.factor % (limiter - 1) / limiter;
	}

	/*
		Returns a pseudo-random whole number (integer) within the range of the given bounds.
	*/
	randomInt(/* [min], max, peek */) {
		let min = 0;
		let max;
		let peek = 0;

		switch (arguments.length) {
			case 0: throw new Error('randomInt called with insufficient parameters');
			case 1: max = Math.trunc(arguments[0]); break;
			default: min = Math.trunc(arguments[0]); max = Math.trunc(arguments[1]); peek = arguments[2] || 0; break;
		}
		if (!Number.isInteger(min) || !Number.isInteger(max) || !Number.isInteger(peek)) throw new Error(`randomInt called with invalid parameters, ${JSON.stringify(arguments)}`);

		return Math.floor(this.random(peek) * (max - min + 1)) + min;
	}

	/*
		Returns a pseudo-random real number (floating-point) within the range of the given bounds.

		NOTE: Unlike with its sibling function `random()`, the `max` parameter
		is exclusive, not inclusive—i.e. the range goes to, but does not include,
		the given value.
		actually, i'm not sure it's possible to ever roll min either.
	*/
	randomFloat(/* [min], max, peek */) {
		let min;
		let max;
		let peek = 0;

		switch (arguments.length) {
			case 0: throw new Error('randomFloat called with insufficient parameters');
			case 1: min = 0.0; max = Number(arguments[0]); break;
			default: min = Number(arguments[0]); max = Number(arguments[1]); peek = arguments[2] || 0; break;
		}
		if (!Number.isFinite(min) || !Number.isFinite(max) || !Number.isInteger(peek)) throw new Error(`randomFloat called with invalid parameters, ${JSON.stringify(arguments)}`);

		return this.random() * (max - min) + min;
	}

	/*
		Randomly shuffles an array and returns it.
	*/
	shuffle(array, mutate = true) {
		if (!Array.isArray(array)) throw new Error(`shuffle expected an array, got ${typeof array}`);
		if (array.length === 0) return;

		const arr = mutate ? array : clone(array);
		for (let i = arr.length - 1; i > 0; --i) {
			const j = this.randomInt(0, i);

			if (i === j) 	continue;

			// [arr[i], arr[j]] = [arr[j], arr[i]];
			const swap = arr[i];
			arr[i] = arr[j];
			arr[j] = swap;
		}

		return arr;
	}

	toShuffled(array) {
		return this.shuffle(array, false);
	}

	/*
		Returns a random value from its given arguments.
	*/
	either() {
		const arr = Array.prototype.concat.apply([], arguments);
		if (arr.length === 0) return;

		return arr[this.randomInt(arr.length - 1)];
	}

	/**
	 * function to peek into the list of n future random values without altering the pull value
	 * @param {number} depth how many numbers to return
	 * @returns {Array} list of predicted random numbers ahead
	 */
	peek(depth = 1) {
		if (!Number.isInteger(depth)) return console.error(`can't look ahead ${depth} times`);
		if (depth > 9000) return console.error('it\'s over 9000!');
		const result = [];
		for (let i = 1; i <= depth; ++i) result.push(this.random(i));
		return result;
	}

	/**
	 * string to seed converter
	 * @param {string} string to convert
	 * @returns {number [0.25, 1]} float between 0.25 and 1, inclusive
	 */
	str2int(string) {
		let sum = 0;
		let count = 0;
		for (const char of string) {
			const code = char.charCodeAt();
			// i don't want your emojis and non-ascii characters
			if (code < 32 || code > 127) continue;
			sum += code - 32;
			++count;
		}
		// some strings might lack valid characters to count, especially empty strings
		return count ? sum / count / 127 + 0.25 : 0.25;
	}

	/**
	 * test function to check the random distribution
	 * @param {number} count how many times to roll
	 * @param {number} granularity how many baskets to fill
	 * @param {boolean} advancerng increase the actual prng pull value
	 * @returns {Array} how many times each basket was hit
	 */
	test(count = 10, granularity = 10, advancerng) {
		const distribution = new Array(granularity).fill(0);
		for (let i = 1; i <= count; ++i) ++distribution[Math.floor(this.random(i) * granularity)];
		if (advancerng) this.pull += count;
		return distribution;
	}
}
window.PRNG = PRNG;


code:
/***********************************************************************************************************************

	sugarcube.js

	Copyright © 2013–2021 Thomas Michael Edwards <thomasmedwards@gmail.com>. All rights reserved.
	Use of this source code is governed by a BSD 2-clause "Simplified" License, which may be found in the LICENSE file.

***********************************************************************************************************************/
/*
	global Alert, Browser, Config, Dialog, Engine, Fullscreen, Has, LoadScreen, SimpleStore, L10n, Macro, Passage,
	       Save, Scripting, Setting, SimpleAudio, State, Story, UI, UIBar, DebugBar, Util, Visibility, Wikifier
	, Links, idb
*/
/* eslint-disable no-var */

/*
	Version object.
*/
var version = Object.freeze({
	title      : 'SugarCube',
	major      : '{{BUILD_VERSION_MAJOR}}',
	minor      : '{{BUILD_VERSION_MINOR}}',
	patch      : '{{BUILD_VERSION_PATCH}}',
	prerelease : '{{BUILD_VERSION_PRERELEASE}}',
	build      : '{{BUILD_VERSION_BUILD}}',
	date       : new Date('{{BUILD_VERSION_DATE}}'),
	/* legacy */
	extensions : {},
	/* /legacy */

	toString() {
		'use strict';

		const prerelease = this.prerelease ? `-${this.prerelease}` : '';
		return `${this.major}.${this.minor}.${this.patch}${prerelease}+${this.build}`;
	},

	short() {
		'use strict';

		const prerelease = this.prerelease ? `-${this.prerelease}` : '';
		return `${this.title} (v${this.major}.${this.minor}.${this.patch}${prerelease})`;
	},

	long() {
		'use strict';

		return `${this.title} v${this.toString()} (${this.date.toUTCString()})`;
	}
});

/* eslint-disable no-unused-vars */
/*
	Internal variables.
*/
// Temporary state object.
var TempState = {};

// Legacy macros object.
var macros = {};

// Post-display task callbacks object.
var postdisplay = {};

// Post-render task callbacks object.
var postrender = {};

// Pre-display task callbacks object.
var predisplay = {};

// Pre-history task callbacks object.
var prehistory = {};

// Pre-render task callbacks object.
var prerender = {};

// Session storage manager object.
var session = null;

// Settings object.
var settings = {};

// Setup object.
var setup = {};

// Persistant storage manager object.
var storage = null;

/*
	Legacy aliases.
*/
var browser       = Browser;
var config        = Config;
var has           = Has;
var History       = State;
var state         = State;
var tale          = Story;
var TempVariables = State.temporary;
/* eslint-enable no-unused-vars */

/*
	Global `SugarCube` object.  Allows scripts to detect if they're running in SugarCube by
	testing for the object (e.g. `"SugarCube" in window`) and contains exported identifiers
	for debugging purposes.
*/
window.SugarCube = {};

/*
	Main function, entry point for the story.
*/
jQuery(() => {
	'use strict';

	if (DEBUG) { console.log('[SugarCube/main()] Document loaded; beginning startup.'); }

	/*
		WARNING!

		The ordering of the code within this function is critically important,
		so be careful when mucking around with it.
	*/
	try {
		// Acquire an initial lock for and initialize the loading screen.
		const lockId = LoadScreen.lock();
		LoadScreen.init();

		// Normalize the document.
		if (document.normalize) {
			document.normalize();
		}

		// Load the story data (must be done before most anything else).
		Story.load();

		// Instantiate the storage and session objects.
		// NOTE: `SimpleStore.create(storageId, persistent)`
		storage = SimpleStore.create(Story.domId, true);
		session = SimpleStore.create(Story.domId, false);

		// Initialize the user interface (must be done before story initialization, specifically before scripts).
		Dialog.init();
		UIBar.init();
		Engine.init();

		// Initialize the story (largely load the user styles, scripts, and widgets).
		Story.init();

		// Initialize the localization (must be done after story initialization).
		L10n.init();

		// Alert when the browser is degrading required capabilities (must be done after localization initialization).
		if (!session.has('rcWarn') && storage.name === 'cookie') {
			/* eslint-disable no-alert */
			session.set('rcWarn', 1);
			window.alert(L10n.get('warningNoWebStorage'));
			/* eslint-enable no-alert */
		}

		// Initialize the saves (must be done after story initialization, but before engine start).
		Save.init();

		// Initialize the settings.
		Setting.init();

		// Initialize indexedDB
		idb.init(Story.domId);

		// Initialize hotkeys
		Links.init();

		// Initialize the macros.
		Macro.init();

		// Start the engine (should be done as late as possible, but before interface startup).
		Engine.start();

		// Initialize the debug bar interface (should be done as late as possible, but before interface startup).
		if (Config.debug) {
			DebugBar.init();
		}

		// Set a recurring timer to start the interfaces (necessary due to DOM readiness issues in some browsers).
		const $window    = $(window);
		const vprCheckId = setInterval(() => {
			// If `$window.width()` returns a zero value, bail out and wait.
			if (!$window.width()) {
				return;
			}

			// Clear the recurring timer.
			clearInterval(vprCheckId);

			// Start the UI bar interface.
			UIBar.start();

			// Start the debug bar interface.
			if (Config.debug) {
				DebugBar.start();
			}

			// Trigger the `:storyready` global synthetic event.
			jQuery.event.trigger({ type : ':storyready' });

			// Release the loading screen lock after a short delay.
			setTimeout(() => LoadScreen.unlock(lockId), Engine.minDomActionDelay * 2);
		}, Engine.minDomActionDelay);

		// Finally, export identifiers for debugging purposes.
		Object.defineProperty(window, 'SugarCube', {
			// WARNING: We need to assign new values at points, so seal it, do not freeze it.
			value : Object.seal(Object.assign(Object.create(null), {
				Browser,
				Config,
				Dialog,
				Engine,
				Fullscreen,
				Has,
				L10n,
				Macro,
				Passage,
				Save,
				Scripting,
				Setting,
				SimpleAudio,
				State,
				Story,
				UI,
				UIBar,
				DebugBar,
				Util,
				Visibility,
				Wikifier,
				session,
				settings,
				setup,
				storage,
				version
			}))
		});

		if (DEBUG) { console.log('[SugarCube/main()] Startup complete; story ready.'); }
	}
	catch (ex) {
		console.error(ex);
		LoadScreen.clear();
		return Alert.fatal(null, ex.message, ex);
	}
});


code:
(function (window, document, jQuery, undefined) {
"use strict";


code:
<!DOCTYPE html>

<meta charset="UTF-8" />
<title>SugarCube</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />

'{{BUILD_CSS_SOURCE}}'

JavaScript must be enabled to play.

Browser lacks capabilities required to play.

Upgrade or switch to another browser.

Loading…

"STORY"


code:
########################################################################################################################
#
# sugarcube-2.py
#
# Copyright (c) 2013-2021 Thomas Michael Edwards <thomasmedwards@gmail.com>. All rights reserved.
# Use of this source code is governed by a BSD 2-clause "Simplified" License, which may be found in the LICENSE file.
#
########################################################################################################################

import os, os.path, header
from collections import OrderedDict

class Header (header.Header):

	def filesToEmbed(self):
		userLibPath = self.path + os.sep + 'userlib.js'
		if os.path.isfile(userLibPath):
			return OrderedDict([
				('"USER_LIB"', userLibPath)
			])
		else:
			return OrderedDict()

	def storySettings(self):
		return "SugarCube 2.x does not support the StorySettings special passage.\n\nInstead, you should use its configuration object, Config.\n    See: http://www.motoslave.net/sugarcube/2/docs/#config-api"

	def isEndTag(self, name, tag):
		return (name == ('/' + tag) or name == ('end' + tag))

	def nestedMacros(self):
		return [
				# standard macros
				'append',
				'button',
				'capture',
				'createaudiogroup',
				'createplaylist',
				'cycle',
				'for',
				'if',
				'link',
				'linkappend',
				'linkprepend',
				'linkreplace',
				'listbox',
				'nobr',
				'prepend',
				'repeat',
				'replace',
				'script',
				'switch',
				'silently',
				'timed',
				'type',
				'widget',
				# deprecated macros
				'click'
			]

	def passageTitleColor(self, passage):
		additionalSpecialPassages = [
				'PassageDone',
				'PassageFooter',
				'PassageHeader',
				'PassageReady',
				'StoryBanner',
				'StoryCaption',
				'StoryDisplayTitle',
				'StoryInterface',
				'StoryShare'
			]
		if passage.isStylesheet():
			return ((111, 49, 83), (234, 123, 184))
		elif passage.isScript():
			return ((89, 66, 28), (226, 170, 80))
		elif ('widget' in passage.tags):
			return ((80, 106, 26), (134, 178, 44))
		elif passage.isInfoPassage() or (passage.title in additionalSpecialPassages):
			return ((28, 89, 74), (41, 214, 113))
		elif passage.title == 'Start':
			return ('#4ca333', '#4bdb24')

	def passageChecks(self):
		return super(Header, self).passageChecks()


SugarCube

description: A full featured, highly customizable story format.  See its documentation.

author: Thomas Michael Edwards


code:
<!DOCTYPE html>

<meta charset="UTF-8" />
<title>{{STORY_NAME}}</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />

'{{BUILD_CSS_SOURCE}}'

JavaScript must be enabled to play.

Browser lacks capabilities required to play.

Upgrade or switch to another browser.

Loading…

	{{STORY_DATA}}


code:
/***********************************************************************************************************************

	ui.js

	Copyright © 2013–2021 Thomas Michael Edwards <thomasmedwards@gmail.com>. All rights reserved.
	Use of this source code is governed by a BSD 2-clause "Simplified" License, which may be found in the LICENSE file.

***********************************************************************************************************************/
/*
	global Alert, Browser, Config, Dialog, Engine, Has, L10n, Save, Setting, State, Story, Util, Wikifier, idb
	       errorPrologRegExp, settings
*/

var UI = (() => { // eslint-disable-line no-unused-vars, no-var
	'use strict';

	/*******************************************************************************************************************
		UI Functions, Core.
	*******************************************************************************************************************/
	function uiAssembleLinkList(passage, listEl) {
		let list = listEl;

		// Cache the values of `Config.debug` and `Config.cleanupWikifierOutput`,
		// then disable them during this method's run.
		const debugState = Config.debug;
		const cleanState = Config.cleanupWikifierOutput;
		Config.debug = false;
		Config.cleanupWikifierOutput = false;

		try {
			if (list == null) { // lazy equality for null
				list = document.createElement('ul');
			}

			// Wikify the content of the given source passage into a fragment.
			const frag = document.createDocumentFragment();
			new Wikifier(frag, Story.get(passage).processText().trim());

			// Gather the text of any error elements within the fragment…
			const errors = [...frag.querySelectorAll('.error')]
				.map(errEl => errEl.textContent.replace(errorPrologRegExp, ''));

			// …and throw an exception, if there were any errors.
			if (errors.length > 0) {
				throw new Error(errors.join('; '));
			}

			while (frag.hasChildNodes()) {
				const node = frag.firstChild;

				// Create list items for -element nodes.
				if (node.nodeType === Node.ELEMENT_NODE && node.nodeName.toUpperCase() === 'A') {
					const li = document.createElement('li');
					list.appendChild(li);
					li.appendChild(node);
				}

				// Discard non--element nodes.
				else {
					frag.removeChild(node);
				}
			}
		}
		finally {
			// Restore the `Config` settings to their original values.
			Config.cleanupWikifierOutput = cleanState;
			Config.debug = debugState;
		}

		return list;
	}

	/*******************************************************************************************************************
		UI Functions, Built-ins.
	*******************************************************************************************************************/
	function uiOpenAlert(message, /* options, closeFn */ ...args) {
		jQuery(Dialog.setup(L10n.get('alertTitle'), 'alert'))
			.append(
				  `<p>${message}</p><ul class="buttons">`
				+ `<li><button id="alert-ok" class="ui-close">${L10n.get(['alertOk', 'ok'])}</button></li>`
				+ '
'
			);
		Dialog.open(...args);
	}

	function uiOpenJumpto(/* options, closeFn */ ...args) {
		uiBuildJumpto();
		Dialog.open(...args);
	}

	function uiOpenRestart(/* options, closeFn */ ...args) {
		uiBuildRestart();
		Dialog.open(...args);
	}

	function uiOpenSaves(/* options, closeFn */ ...args) {
		// use idb when available
		if (idb.active) {
			Dialog.create('saves', 'saves').append($(document.createElement('h3')).addClass('saves-loading').text('Loading the save list, please wait...'));
			idb.saveList();
		}
		else uiBuildSaves();
		Dialog.open(...args);
	}

	function uiOpenSettings(/* options, closeFn */ ...args) {
		uiBuildSettings();
		Dialog.open(...args);
	}

	function uiOpenShare(/* options, closeFn */ ...args) {
		uiBuildShare();
		Dialog.open(...args);
	}

	function uiBuildAutoload() {
		if (DEBUG) { console.log('[UI/uiBuildAutoload()]'); }

		jQuery(Dialog.setup(L10n.get('autoloadTitle'), 'autoload'))
			.append(
				/* eslint-disable max-len */
				  `<p>${L10n.get('autoloadPrompt')}</p><ul class="buttons">`
				+ `<li><button id="autoload-ok" class="ui-close">${L10n.get(['autoloadOk', 'ok'])}</button></li>`
				+ `<li><button id="autoload-cancel" class="ui-close">${L10n.get(['autoloadCancel', 'cancel'])}</button></li>`
				+ '
'
				/* eslint-enable max-len */
			);

		// Add an additional delegated click handler for the `.ui-close` elements to handle autoloading.
		jQuery(document).one('click.autoload', '.ui-close', ev => {
			const isAutoloadOk = ev.target.id === 'autoload-ok';
			jQuery(document).one(':dialogclosed', () => {
				if (DEBUG) { console.log(`\tattempting autoload: "${Save.autosave.get().title}"`); }

				if (!isAutoloadOk || !Save.autosave.load()) {
					Engine.play(Config.passages.start);
				}
			});
		});

		return true;
	}

	function uiBuildJumpto() {
		if (DEBUG) { console.log('[UI/uiBuildJumpto()]'); }

		const list = document.createElement('ul');

		jQuery(Dialog.setup(L10n.get('jumptoTitle'), 'jumpto list'))
			.append(list);

		const expired = State.expired.length;

		for (let i = State.size - 1; i >= 0; --i) {
			if (i === State.activeIndex) {
				continue;
			}

			const passage = Story.get(State.history[i].title);

			if (passage && passage.tags.includes('bookmark')) {
				jQuery(document.createElement('li'))
					.append(
						jQuery(document.createElement('a'))
							.ariaClick({ one : true }, (function (idx) {
								return () => jQuery(document).one(':dialogclosed', () => Engine.goTo(idx));
							})(i))
							.addClass('ui-close')
							.text(`${L10n.get('jumptoTurn')} ${expired + i + 1}: ${passage.description()}`)
					)
					.appendTo(list);
			}
		}

		if (!list.hasChildNodes()) {
			jQuery(list).append(`<li><a><em>${L10n.get('jumptoUnavailable')}</em></a></li>`);
		}
	}

	function uiBuildRestart() {
		if (DEBUG) { console.log('[UI/uiBuildRestart()]'); }

		jQuery(Dialog.setup(L10n.get('restartTitle'), 'restart'))
			.append(
				/* eslint-disable max-len */
				  `<p>${L10n.get('restartPrompt')}</p><ul class="buttons">`
				+ `<li><button id="restart-ok">${L10n.get(['restartOk', 'ok'])}</button></li>`
				+ `<li><button id="restart-cancel" class="ui-close">${L10n.get(['restartCancel', 'cancel'])}</button></li>`
				+ '
'
				/* eslint-enable max-len */
			)
			.find('#restart-ok')
			/*
				Instead of adding '.ui-close' to '#restart-ok' (to receive the use of the default
				delegated dialog close handler), we set up a special case close handler here.  We
				do this to ensure that the invocation of `Engine.restart()` happens after the dialog
				has fully closed.  If we did not, then a race condition could occur, causing display
				shenanigans.
			*/
			.ariaClick({ one : true }, () => {
				jQuery(document).one(':dialogclosed', () => Engine.restart());
				Dialog.close();
			});

		return true;
	}

	function uiBuildSaves() {
		const savesAllowed = typeof Config.saves.isAllowed !== 'function' || Config.saves.isAllowed();

		function createActionItem(bId, bClass, bText, bAction) {
			const $btn = jQuery(document.createElement('button'))
				.attr('id', `saves-${bId}`)
				.html(bText);

			if (bClass) {
				$btn.addClass(bClass);
			}

			if (bAction) {
				$btn.ariaClick(bAction);
			}
			else {
				$btn.ariaDisabled(true);
			}

			return jQuery(document.createElement('li'))
				.append($btn);
		}

		function createSaveList() {
			function createButton(bId, bClass, bText, bSlot, bAction) {
				const $btn = jQuery(document.createElement('button'))
					.attr('id', `saves-${bId}-${bSlot}`)
					.addClass(bId)
					.html(bText);

				if (bClass) {
					$btn.addClass(bClass);
				}

				if (bAction) {
					if (bSlot === 'auto') {
						$btn.ariaClick({
							label : `${bText} ${L10n.get('savesLabelAuto')}`
						}, () => bAction());
					}
					else {
						$btn.ariaClick({
							label : `${bText} ${L10n.get('savesLabelSlot')} ${bSlot + 1}`
						}, () => bAction(bSlot));
					}
				}
				else {
					$btn.ariaDisabled(true);
				}

				return $btn;
			}

			const saves  = Save.get();
			const $tbody = jQuery(document.createElement('tbody'));

			if (Save.autosave.ok()) {
				const $tdSlot = jQuery(document.createElement('td'));
				const $tdLoad = jQuery(document.createElement('td'));
				const $tdDesc = jQuery(document.createElement('td'));
				const $tdDele = jQuery(document.createElement('td'));

				// Add the slot ID.
				jQuery(document.createElement('b'))
					.attr({
						title        : L10n.get('savesLabelAuto'),
						'aria-label' : L10n.get('savesLabelAuto')
					})
					.text('A') // '\u25C6' Black Diamond
					.appendTo($tdSlot);

				if (saves.autosave) {
					// Add the load button.
					$tdLoad.append(
						createButton('load', 'ui-close', L10n.get('savesLabelLoad'), 'auto', () => {
							jQuery(document).one(':dialogclosed', () => Save.autosave.load());
						})
					);

					// Add the description (title and datestamp).
					jQuery(document.createElement('div'))
						.text(saves.autosave.title)
						.appendTo($tdDesc);
					jQuery(document.createElement('div'))
						.addClass('datestamp')
						.html(
							saves.autosave.date
								? `${new Date(saves.autosave.date).toLocaleString()}`
								: `<em>${L10n.get('savesUnknownDate')}</em>`
						)
						.appendTo($tdDesc);

					// Add the delete button.
					$tdDele.append(
						createButton('delete', null, L10n.get('savesLabelDelete'), 'auto', () => {
							Save.autosave.delete();
							uiBuildSaves();
						})
					);
				}
				else {
					// Add the disabled load button.
					$tdLoad.append(
						createButton('load', null, L10n.get('savesLabelLoad'), 'auto')
					);

					// Add the description.
					$tdDesc.addClass('empty').text('\u2022\u00a0\u00a0\u2022\u00a0\u00a0\u2022');

					// Add the disabled delete button.
					$tdDele.append(
						createButton('delete', null, L10n.get('savesLabelDelete'), 'auto')
					);
				}

				jQuery(document.createElement('tr'))
					.append($tdSlot)
					.append($tdLoad)
					.append($tdDesc)
					.append($tdDele)
					.appendTo($tbody);
			}

			for (let i = 0, iend = saves.slots.length; i < iend; ++i) {
				const $tdSlot = jQuery(document.createElement('td'));
				const $tdLoad = jQuery(document.createElement('td'));
				const $tdDesc = jQuery(document.createElement('td'));
				const $tdDele = jQuery(document.createElement('td'));

				// Add the slot ID.
				$tdSlot.append(document.createTextNode(i + 1));

				if (saves.slots[i]) {
					// Add the save and load buttons.
					$tdLoad.append(
						createButton('save', 'ui-close', L10n.get('savesLabelSave'), i, Save.slots.save),
						createButton('load', 'ui-close', L10n.get('savesLabelLoad'), i, slot => {
							jQuery(document).one(':dialogclosed', () => Save.slots.load(slot));
						})
					);

					// Add the description (title and datestamp).
					jQuery(document.createElement('div'))
						.text(saves.slots[i].title)
						.appendTo($tdDesc);
					jQuery(document.createElement('div'))
						.addClass('datestamp')
						.html(
							saves.slots[i].date
								? `${new Date(saves.slots[i].date).toLocaleString()}`
								: `<em>${L10n.get('savesUnknownDate')}</em>`
						)
						.appendTo($tdDesc);

					// Add the delete button.
					$tdDele.append(
						createButton('delete', null, L10n.get('savesLabelDelete'), i, slot => {
							Save.slots.delete(slot);
							uiBuildSaves();
						})
					);
				}
				else {
					// Add the save button.
					$tdLoad.append(
						createButton('save', 'ui-close', L10n.get('savesLabelSave'), i, savesAllowed ? Save.slots.save : null)
					);

					// Add the description.
					$tdDesc.addClass('empty').text('\u2022\u00a0\u00a0\u2022\u00a0\u00a0\u2022');

					// Add the disabled delete button.
					$tdDele.append(
						createButton('delete', null, L10n.get('savesLabelDelete'), i)
					);
				}

				jQuery(document.createElement('tr'))
					.append($tdSlot)
					.append($tdLoad)
					.append($tdDesc)
					.append($tdDele)
					.appendTo($tbody);
			}

			return jQuery(document.createElement('table'))
				.attr('id', 'saves-list')
				.append($tbody);
		}

		if (DEBUG) { console.log('[UI/uiBuildSaves()]'); }

		const $dialogBody = jQuery(Dialog.setup(L10n.get('savesTitle'), 'saves'));
		const savesOk     = Save.ok();
		const fileOk      = Has.fileAPI && (Config.saves.tryDiskOnMobile || !Browser.isMobile.any());

		// Add saves list.
		if (savesOk) {
			$dialogBody.append(createSaveList());
		}

		// Add button bar items (export, import, and clear).
		if (savesOk || fileOk) {
			const $btnBar = jQuery(document.createElement('ul'))
				.addClass('buttons')
				.appendTo($dialogBody);

			if (fileOk) {
				$btnBar.append(createActionItem(
					'export',
					'ui-close',
					L10n.get('savesLabelExport'),
					savesAllowed ? () => Save.export() : null
				));
				if (navigator.clipboard) {
					$btnBar.append(createActionItem(
						'toClipboard',
						'ui-close',
						L10n.get('savesLabelToClipboard'),
						savesAllowed ? () => navigator.clipboard.writeText(Save.serialize()) : null
					));
				}
				$btnBar.append(createActionItem(
					'import',
					null,
					L10n.get('savesLabelImport'),
					() => $dialogBody.find('#saves-import-file').trigger('click')
				));

				// Add the hidden `input[type=file]` element which will be triggered by the `#saves-import` button.
				jQuery(document.createElement('input'))
					.css({
						display    : 'block',
						visibility : 'hidden',
						position   : 'fixed',
						left       : '-9999px',
						top        : '-9999px',
						width      : '1px',
						height     : '1px'
					})
					.attr({
						type          : 'file',
						id            : 'saves-import-file',
						tabindex      : -1,
						'aria-hidden' : true
					})
					.on('change', ev => {
						jQuery(document).one(':dialogclosed', () => Save.import(ev));
						Dialog.close();
					})
					.appendTo($dialogBody);
			}

			if (savesOk) {
				$btnBar.append(createActionItem(
					'clear',
					null,
					L10n.get('savesLabelClear'),
					Save.autosave.has() || !Save.slots.isEmpty()
						? () => {
							Save.clear();
							uiBuildSaves();
						}
						: null
				));
			}

			const $idbBar = jQuery(document.createElement('ul'))
				.addClass('buttons')
				.appendTo($dialogBody);

			$idbBar.append(createActionItem(
				'idbToggleSaves',
				null,
				'Enable indexedDB',
				!idb.lock
					? () => {
						Dialog.create('saves', 'saves').append($(document.createElement('h3')).addClass('saves-loading').text('Loading the save list, please wait...'));
						idb.updateSettings('active', true);
						idb.saveList();
					}
					: null
			));

			return true;
		}

		uiOpenAlert(L10n.get('savesIncapable'));
		return false;
	}

	function uiBuildSettings() {
		if (DEBUG) { console.log('[UI/uiBuildSettings()]'); }

		const $dialogBody = jQuery(Dialog.setup(L10n.get('settingsTitle'), 'settings'));

		Setting.forEach(control => {
			if (control.type === Setting.Types.Header) {
				const name     = control.name;
				const id       = Util.slugify(name);
				const $header  = jQuery(document.createElement('div'));
				const $heading = jQuery(document.createElement('h2'));

				$header
					.attr('id', `header-body-${id}`)
					.append($heading)
					.appendTo($dialogBody);
				$heading
					.attr('id', `header-heading-${id}`)
					.wiki(name);

				// Set up the description, if any.
				if (control.desc) {
					jQuery(document.createElement('p'))
						.attr('id', `header-desc-${id}`)
						.wiki(control.desc)
						.appendTo($header);
				}

				return;
			}

			const name        = control.name;
			const id          = Util.slugify(name);
			const $setting    = jQuery(document.createElement('div'));
			const $label      = jQuery(document.createElement('label'));
			const $controlBox = jQuery(document.createElement('div'));
			let $control;

			// Set up the label+control wrapper.
			jQuery(document.createElement('div'))
				.append($label)
				.append($controlBox)
				.appendTo($setting);

			// Set up the description, if any.
			if (control.desc) {
				jQuery(document.createElement('p'))
					.attr('id', `setting-desc-${id}`)
					.wiki(control.desc)
					.appendTo($setting);
			}

			// Set up the label.
			$label
				.attr({
					id  : `setting-label-${id}`,
					for : `setting-control-${id}` // must be in sync with $control's ID (see below)
				})
				.wiki(control.label);

			// Set up the control.
			if (settings[name] == null) { // lazy equality for null
				settings[name] = control.default;
			}

			switch (control.type) {
			case Setting.Types.Toggle:
				$control = jQuery(document.createElement('button'));

				if (settings[name]) {
					$control
						.addClass('enabled')
						.text(L10n.get('settingsOn'));
				}
				else {
					$control
						.text(L10n.get('settingsOff'));
				}

				$control.ariaClick(function () {
					if (settings[name]) {
						jQuery(this)
							.removeClass('enabled')
							.text(L10n.get('settingsOff'));
						settings[name] = false;
					}
					else {
						jQuery(this)
							.addClass('enabled')
							.text(L10n.get('settingsOn'));
						settings[name] = true;
					}

					Setting.save();

					if (control.hasOwnProperty('onChange')) {
						control.onChange.call({
							name,
							value   : settings[name],
							default : control.default
						});
					}
				});
				break;

			case Setting.Types.List:
				$control = jQuery(document.createElement('select'));

				for (let i = 0, iend = control.list.length; i < iend; ++i) {
					jQuery(document.createElement('option'))
						.val(i)
						.text(control.list[i])
						.appendTo($control);
				}

				$control
					.val(control.list.indexOf(settings[name]))
					.attr('tabindex', 0)
					.on('change', function () {
						settings[name] = control.list[Number(this.value)];
						Setting.save();

						if (control.hasOwnProperty('onChange')) {
							control.onChange.call({
								name,
								value   : settings[name],
								default : control.default,
								list    : control.list
							});
						}
					});
				break;

			case Setting.Types.Range:
				$control = jQuery(document.createElement('input'));

				// NOTE: Setting the value with `<jQuery>.val()` can cause odd behavior
				// in Edge if it's called before the type is set, so we use the `value`
				// content attribute here to dodge the entire issue.
				$control
					.attr({
						type     : 'range',
						min      : control.min,
						max      : control.max,
						step     : control.step,
						value    : settings[name],
						tabindex : 0
					})
					.on('change input', function () {
						settings[name] = Number(this.value);
						Setting.save();

						if (control.hasOwnProperty('onChange')) {
							control.onChange.call({
								name,
								value   : settings[name],
								default : control.default,
								min     : control.min,
								max     : control.max,
								step    : control.step
							});
						}
					})
					.on('keypress', ev => {
						if (ev.which === 13) {
							ev.preventDefault();
							$control.trigger('change');
						}
					});
				break;
			}

			$control
				.attr('id', `setting-control-${id}`)
				.appendTo($controlBox);

			$setting
				.attr('id', `setting-body-${id}`)
				.appendTo($dialogBody);
		});

		// Add the button bar.
		$dialogBody
			.append(
				  '
'
				+     `<li><button id="settings-ok" class="ui-close">${L10n.get(['settingsOk', 'ok'])}</button></li>`
				+     `<li><button id="settings-reset">${L10n.get('settingsReset')}</button></li>`
				+ '
'
			)
			.find('#settings-reset')
			/*
				Instead of adding '.ui-close' to '#settings-reset' (to receive the use of the default
				delegated dialog close handler), we set up a special case close handler here.  We
				do this to ensure that the invocation of `window.location.reload()` happens after the
				dialog has fully closed.  If we did not, then a race condition could occur, causing
				display shenanigans.
			*/
			.ariaClick({ one : true }, () => {
				jQuery(document).one(':dialogclosed', () => {
					Setting.reset();
					window.location.reload();
				});
				Dialog.close();
			});

		return true;
	}

	function uiBuildShare() {
		if (DEBUG) { console.log('[UI/uiBuildShare()]'); }

		try {
			jQuery(Dialog.setup(L10n.get('shareTitle'), 'share list'))
				.append(uiAssembleLinkList('StoryShare'));
		}
		catch (ex) {
			console.error(ex);
			Alert.error('StoryShare', ex.message);
			return false;
		}

		return true;
	}

	/*******************************************************************************************************************
		Module Exports.
	*******************************************************************************************************************/
	return Object.freeze(Object.defineProperties({}, {
		/*
			UI Functions, Core.
		*/
		assembleLinkList : { value : uiAssembleLinkList },

		/*
			UI Functions, Built-ins.
		*/
		alert         : { value : uiOpenAlert },
		jumpto        : { value : uiOpenJumpto },
		restart       : { value : uiOpenRestart },
		saves         : { value : uiOpenSaves },
		settings      : { value : uiOpenSettings },
		share         : { value : uiOpenShare },
		buildAutoload : { value : uiBuildAutoload },
		buildJumpto   : { value : uiBuildJumpto },
		buildRestart  : { value : uiBuildRestart },
		buildSaves    : { value : uiBuildSaves },
		buildSettings : { value : uiBuildSettings },
		buildShare    : { value : uiBuildShare },

		/*
			Legacy Aliases.
		*/
		// `UIBar` methods.
		/* global UIBar */
		stow                     : { value : () => UIBar.stow() },
		unstow                   : { value : () => UIBar.unstow() },
		setStoryElements         : { value : () => UIBar.update() },
		// `Dialog` methods.
		isOpen                   : { value : (...args) => Dialog.isOpen(...args) },
		body                     : { value : () => Dialog.body() },
		setup                    : { value : (...args) => Dialog.setup(...args) },
		addClickHandler          : { value : (...args) => Dialog.addClickHandler(...args) },
		open                     : { value : (...args) => Dialog.open(...args) },
		close                    : { value : (...args) => Dialog.close(...args) },
		resize                   : { value : () => Dialog.resize() },
		// Deprecated method names.
		buildDialogAutoload      : { value : uiBuildAutoload },
		buildDialogJumpto        : { value : uiBuildJumpto },
		buildDialogRestart       : { value : uiBuildRestart },
		buildDialogSaves         : { value : uiBuildSaves },
		buildDialogSettings      : { value : uiBuildSettings },
		buildDialogShare         : { value : uiBuildShare },
		buildLinkListFromPassage : { value : uiAssembleLinkList }
	}));
})();


code:
/***********************************************************************************************************************

	uibar.js

	Copyright © 2013–2021 Thomas Michael Edwards <thomasmedwards@gmail.com>. All rights reserved.
	Use of this source code is governed by a BSD 2-clause "Simplified" License, which may be found in the LICENSE file.

***********************************************************************************************************************/
/*
	global Alert, Dialog, Engine, L10n, Setting, State, Story, UI, Config, setDisplayTitle, setPageElement
*/

var UIBar = (() => { // eslint-disable-line no-unused-vars, no-var
	'use strict';

	// UI bar element cache.
	let _$uiBar = null;

	/*******************************************************************************
		UI Bar Functions.
	*******************************************************************************/

	function uiBarDestroy() {
		if (DEBUG) { console.log('[UIBar/uiBarDestroy()]'); }

		if (!_$uiBar) {
			return;
		}

		// Hide the UI bar.
		_$uiBar.hide();

		// Remove its namespaced events.
		jQuery(document).off('.ui-bar');

		// Remove its styles.
		jQuery(document.head).find('#style-ui-bar').remove();

		// Remove it from the DOM.
		_$uiBar.remove();

		// Drop the reference to the element.
		_$uiBar = null;
	}

	function uiBarHide() {
		if (_$uiBar) {
			_$uiBar.hide();
		}

		return this;
	}

	function uiBarInit() {
		if (DEBUG) { console.log('[UIBar/uiBarInit()]'); }

		if (document.getElementById('ui-bar')) {
			return;
		}

		// Generate the UI bar elements.
		const $elems = (() => {
			const toggleLabel   = L10n.get('uiBarToggle');
			const backwardLabel = L10n.get('uiBarBackward');
			const jumptoLabel   = L10n.get('uiBarJumpto');
			const forwardLabel  = L10n.get('uiBarForward');

			return jQuery(document.createDocumentFragment())
				.append(
					/* eslint-disable max-len */
					  '
'
					+     '
'
					+         `<button id="ui-bar-toggle" tabindex="0" title="${toggleLabel}" aria-label="${toggleLabel}"></button>`
					+         '
'
					+             `<button id="history-backward" tabindex="0" title="${backwardLabel}" aria-label="${backwardLabel}">\uE821</button>`
					+             `<button id="history-jumpto" tabindex="0" title="${jumptoLabel}" aria-label="${jumptoLabel}">\uE839</button>`
					+             `<button id="history-forward" tabindex="0" title="${forwardLabel}" aria-label="${forwardLabel}">\uE822</button>`
					+         '
'
					+     '
'
					+     '
'
					+         ''
					+             '

'
					+             '

'
					+             '

'
					+             '

'
					+             '

'
					+         ''
					+         '

'
					+         ''
					+             '

'
					+             '
'
					+                 `<li id="menu-item-saves"><a tabindex="0">${L10n.get('savesTitle')}</a></li>`
					+                 `<li id="menu-item-settings"><a tabindex="0">${L10n.get('settingsTitle')}</a></li>`
					+                 `<li id="menu-item-restart"><a tabindex="0">${L10n.get('restartTitle')}</a></li>`
					+                 `<li id="menu-item-share"><a tabindex="0">${L10n.get('shareTitle')}</a></li>`
					+             '
'
					+         ''
					+     '
'
					+ '
'
					/* eslint-enable max-len */
				);
		})();

		/*
			Cache the UI bar element, since its going to be used often.

			NOTE: We rewrap the element itself, rather than simply using the result
			of `find()`, so that we cache an uncluttered jQuery-wrapper (i.e. `context`
			refers to the element and there is no `prevObject`).
		*/
		_$uiBar = jQuery($elems.find('#ui-bar').get(0));

		// Insert the UI bar elements into the page before the main script.
		$elems.insertBefore('body>script#script-sugarcube');

		// Set up the UI bar's global event handlers.
		jQuery(document)
			// Set up a handler for the history-backward/-forward buttons.
			.on(':historyupdate.ui-bar', (($backward, $forward) => () => {
				$backward.ariaDisabled(State.length < 2);
				$forward.ariaDisabled(State.length === State.size);
			})(jQuery('#history-backward'), jQuery('#history-forward')));
	}

	function uiBarIsHidden() {
		return _$uiBar && _$uiBar.css('display') === 'none';
	}

	function uiBarIsStowed() {
		return _$uiBar && _$uiBar.hasClass('stowed');
	}

	function uiBarShow() {
		if (_$uiBar) {
			_$uiBar.show();
		}

		return this;
	}

	function uiBarStart() {
		if (DEBUG) { console.log('[UIBar/uiBarStart()]'); }

		if (!_$uiBar) {
			return;
		}

		// Set up the #ui-bar's initial state.
		if (
			typeof Config.ui.stowBarInitially === 'boolean'
				? Config.ui.stowBarInitially
				: jQuery(window).width() <= Config.ui.stowBarInitially
		) {
			uiBarStow(true);
		}

		// Set up the #ui-bar-toggle and #ui-bar-history widgets.
		jQuery('#ui-bar-toggle')
			.ariaClick({
				label : L10n.get('uiBarToggle')
			}, () => _$uiBar.toggleClass('stowed'));

		jQuery('#history-backward')
			.ariaDisabled(State.length < 2)
			.ariaClick({
				label : L10n.get('uiBarBackward')
			}, () => Engine.backward());

		if (Story.lookup('tags', 'bookmark').length > 0) {
			jQuery('#history-jumpto')
				.ariaClick({
					label : L10n.get('uiBarJumpto')
				}, () => UI.jumpto());
		}
		else {
			jQuery('#history-jumpto').remove();
		}

		jQuery('#history-forward')
			.ariaDisabled(State.length === State.size)
			.ariaClick({
				label : L10n.get('uiBarForward')
			}, () => Engine.forward());

		if (!Config.history.controls) jQuery('#ui-bar-history').hide();

		// Set up the story display title.
		if (Story.has('StoryDisplayTitle')) {
			setDisplayTitle(Story.get('StoryDisplayTitle').processText());
		}
		else {
			if (TWINE1) { // for Twine 1
				setPageElement('story-title', 'StoryTitle', Story.title);
			}
			else { // for Twine 2
				jQuery('#story-title').text(Story.title);
			}
		}

		// Set up the dynamic page elements.
		if (!Story.has('StoryCaption')) {
			jQuery('#story-caption').remove();
		}

		if (!Story.has('StoryMenu')) {
			jQuery('#menu-story').remove();
		}

		if (!Config.ui.updateStoryElements) {
			// We only need to set the story elements here if `Config.ui.updateStoryElements`
			// is falsy, since otherwise they will be set by `Engine.play()`.
			uiBarUpdate();
		}

		// Set up the Saves menu item.
		jQuery('#menu-item-saves a')
			.ariaClick({
				role : 'button'
			}, ev => {
				ev.preventDefault();
				// use idb when available
				if (idb.active) {
					Dialog.create('saves', 'saves').append($(document.createElement('h3')).addClass('saves-loading').text('Loading the save list, please wait...'));
					idb.saveList();
				}
				// but keep the old system just in case
				else UI.buildSaves();
				Dialog.open();
			})
			.text(L10n.get('savesTitle'));

		// Set up the Settings menu item.
		if (!Setting.isEmpty()) {
			jQuery('#menu-item-settings a')
				.ariaClick({
					role : 'button'
				}, ev => {
					ev.preventDefault();
					UI.buildSettings();
					Dialog.open();
				})
				.text(L10n.get('settingsTitle'));
		}
		else {
			jQuery('#menu-item-settings').remove();
		}

		// Set up the Restart menu item.
		jQuery('#menu-item-restart a')
			.ariaClick({
				role : 'button'
			}, ev => {
				ev.preventDefault();
				UI.buildRestart();
				Dialog.open();
			})
			.text(L10n.get('restartTitle'));

		// Set up the Share menu item.
		if (Story.has('StoryShare')) {
			jQuery('#menu-item-share a')
				.ariaClick({
					role : 'button'
				}, ev => {
					ev.preventDefault();
					UI.buildShare();
					Dialog.open();
				})
				.text(L10n.get('shareTitle'));
		}
		else {
			jQuery('#menu-item-share').remove();
		}
	}

	function uiBarStow(noAnimation) {
		if (_$uiBar && !_$uiBar.hasClass('stowed')) {
			let $story;

			if (noAnimation) {
				$story = jQuery('#story');
				$story.addClass('no-transition');
				_$uiBar.addClass('no-transition');
			}

			_$uiBar.addClass('stowed');

			if (noAnimation) {
				setTimeout(() => {
					$story.removeClass('no-transition');
					_$uiBar.removeClass('no-transition');
				}, Engine.minDomActionDelay);
			}
		}

		return this;
	}

	function uiBarUnstow(noAnimation) {
		if (_$uiBar && _$uiBar.hasClass('stowed')) {
			let $story;

			if (noAnimation) {
				$story = jQuery('#story');
				$story.addClass('no-transition');
				_$uiBar.addClass('no-transition');
			}

			_$uiBar.removeClass('stowed');

			if (noAnimation) {
				setTimeout(() => {
					$story.removeClass('no-transition');
					_$uiBar.removeClass('no-transition');
				}, Engine.minDomActionDelay);
			}
		}

		return this;
	}

	function uiBarUpdate() {
		if (DEBUG) { console.log('[UIBar/uiBarUpdate()]'); }

		// Set up the display title, both the document title and page element.
		if (Story.has('StoryDisplayTitle')) {
			setDisplayTitle(Story.get('StoryDisplayTitle').processText());
		}

		if (!_$uiBar) {
			return;
		}

		// Set up the (non-navigation) dynamic page elements.
		setPageElement('story-banner', 'StoryBanner');
		setPageElement('story-subtitle', 'StorySubtitle');
		setPageElement('story-author', 'StoryAuthor');
		setPageElement('story-caption', 'StoryCaption');

		// Set up the #menu-story items.
		const menuStory = document.getElementById('menu-story');

		if (menuStory !== null) {
			jQuery(menuStory).empty();

			if (Story.has('StoryMenu')) {
				try {
					UI.assembleLinkList('StoryMenu', menuStory);
				}
				catch (ex) {
					console.error(ex);
					Alert.error('StoryMenu', ex.message);
				}
			}
		}
	}

	/*******************************************************************************
		Object Exports.
	*******************************************************************************/

	return Object.freeze(Object.defineProperties({}, {
		destroy  : { value : uiBarDestroy },
		hide     : { value : uiBarHide },
		init     : { value : uiBarInit },
		isHidden : { value : uiBarIsHidden },
		isStowed : { value : uiBarIsStowed },
		show     : { value : uiBarShow },
		start    : { value : uiBarStart },
		stow     : { value : uiBarStow },
		unstow   : { value : uiBarUnstow },
		update   : { value : uiBarUpdate },

		// Legacy Functions.
		setStoryElements : { value : uiBarUpdate }
	}));
})();


code:
/***********************************************************************************************************************

	util/enumfrom.js

	Copyright © 2013–2023 Thomas Michael Edwards <thomasmedwards@gmail.com>. All rights reserved.
	Use of this source code is governed by a BSD 2-clause "Simplified" License, which may be found in the LICENSE file.

***********************************************************************************************************************/

/*
	Returns a pseudo-enumeration object created from the given Array, Map, Set,
	or generic object.
*/
function enumFrom(O) { // eslint-disable-line no-unused-vars
	const pEnum = Object.create(null);

	if (O instanceof Array) {
		O.forEach((val, i) => pEnum[String(val)] = i);
	}
	else if (O instanceof Set) {
		// NOTE: Use `<Array>.forEach()` here rather than `<Set>.forEach()`
		// as the latter does not provide the indices we require.
		Array.from(O).forEach((val, i) => pEnum[String(val)] = i);
	}
	else if (O instanceof Map) {
		O.forEach((val, key) => pEnum[String(key)] = val);
	}
	else if (
		O !== null
		&& typeof O === 'object'
		&& Object.getPrototypeOf(O) === Object.prototype
	) {
		Object.assign(pEnum, O);
	}
	else {
		throw new TypeError('enumFrom object parameter must be an Array, Map, Set, or generic object');
	}

	return Object.freeze(Object.defineProperties(pEnum, {
		nameFrom : {
			value(needle) {
				const entry = Object.entries(this).find(entry => entry[1] === needle);
				return entry ? entry[0] : undefined;
			}
		}
	}));
}


code:
/***********************************************************************************************************************

	util/getactiveelement.js

	Copyright © 2013–2023 Thomas Michael Edwards <thomasmedwards@gmail.com>. All rights reserved.
	Use of this source code is governed by a BSD 2-clause "Simplified" License, which may be found in the LICENSE file.

***********************************************************************************************************************/

/*
	Returns `document.activeElement` or `null`.
*/
function getActiveElement() { // eslint-disable-line no-unused-vars
	// IE9 contains a bug where trying to access the active element of an iframe's
	// parent document—i.e., `window.parent.document.activeElement`—will throw an
	// exception, so we must allow for an exception to be thrown.
	//
	// We could simply return `undefined` here, but since the API's default behavior
	// should be to return `document.body` or, when there is no selection, `null`,
	// we choose to return `null` in all non-element cases—i.e., whether it returns
	// `null` or throws an exception.  Just a bit of normalization.
	try {
		return document.activeElement || null;
	}
	catch (ex) {
		return null;
	}
}


code:
/***********************************************************************************************************************

	util/gettypeof.js

	Copyright © 2013–2023 Thomas Michael Edwards <thomasmedwards@gmail.com>. All rights reserved.
	Use of this source code is governed by a BSD 2-clause "Simplified" License, which may be found in the LICENSE file.

***********************************************************************************************************************/

/*
	Returns the value `'null'` for `null`, the value yielded by `typeof` for
	primitives and functions, or the `@@toStringTag` internal property for objects.

	Examples:
		getTypeOf(42n)            → 'bigint'
		getTypeOf(true)           → 'boolean'
		getTypeOf(function () {}) → 'function'
		getTypeOf(42)             → 'number'
		getTypeOf(null)           → 'null'
		getTypeOf("fnord")        → 'string'
		getTypeOf(Symbol("ZETA")) → 'symbol'
		getTypeOf(undefined)      → 'undefined'
		getTypeOf(['a', 'b'])     → 'Array'
		getTypeOf({ a : 'b' })    → 'Object'
		getTypeOf(new Date())     → 'Date'
		getTypeOf(new Map())      → 'Map'
		getTypeOf(new Set())      → 'Set'
		Etc.
*/
var getTypeOf = (() => { // eslint-disable-line no-unused-vars, no-var
	// Cache built-in object method.
	const toString = Object.prototype.toString;
	const slice    = String.prototype.slice;

	function getTypeOf(O) {
		// Special case for `null`, since `typeof` is a buggy piece of shit.
		if (O === null) { return 'null'; }

		const baseType = typeof O;
		return baseType === 'object' ? slice.call(toString.call(O), 8, -1) : baseType;
	}

	return getTypeOf;
})();


code:
/*! console_hack.js | (c) 2015 Thomas Michael Edwards | Licensed under SugarCube's Simple BSD license */
!function(){for(var methods=["assert","clear","count","debug","dir","dirxml","error","exception","group","groupCollapsed","groupEnd","info","log","markTimeline","profile","profileEnd","table","time","timeEnd","timeline","timelineEnd","timeStamp","trace","warn"],length=methods.length,noop=function(){},console=window.console=window.console||{};length--;){var method=methods[length];console[method]||(console[method]=noop)}}();


code:
/*
 * jQuery throttle / debounce - v1.1 - 3/7/2010
 * http://benalman.com/projects/jquery-throttle-debounce-plugin/
 *
 * Copyright (c) 2010 "Cowboy" Ben Alman
 * Dual licensed under the MIT and GPL licenses.
 * http://benalman.com/about/license/
 */
(function(b,c){var $=b.jQuery||b.Cowboy||(b.Cowboy={}),a;$.throttle=a=function(e,f,j,i){var h,d=0;if(typeof f!=="boolean"){i=j;j=f;f=c}function g(){var o=this,m=+new Date()-d,n=arguments;function l(){d=+new Date();j.apply(o,n)}function k(){h=c}if(i&&!h){l()}h&&clearTimeout(h);if(i===c&&m>e){l()}else{if(f!==true){h=setTimeout(i?k:l,i===c?e-m:e)}}}if($.guid){g.guid=j.guid=j.guid||$.guid++}return g};$.debounce=function(d,e,f){return f===c?a(d,e,false):a(d,f,e!==false)}})(this);


code:
/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */

/**
 * 1. Set default font family to sans-serif.
 * 2. Prevent iOS and IE text size adjust after device orientation change,
 *    without disabling user zoom.
 */

html {
  font-family: sans-serif; /* 1 */
  -ms-text-size-adjust: 100%; /* 2 */
  -webkit-text-size-adjust: 100%; /* 2 */
}

/**
 * Remove default margin.
 */

body {
  margin: 0;
}

/* HTML5 display definitions
   ========================================================================== */

/**
 * Correct `block` display not defined for any HTML5 element in IE 8/9.
 * Correct `block` display not defined for `details` or `summary` in IE 10/11
 * and Firefox.
 * Correct `block` display not defined for `main` in IE 11.
 */

article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
main,
menu,
nav,
section,
summary {
  display: block;
}

/**
 * 1. Correct `inline-block` display not defined in IE 8/9.
 * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera.
 */

audio,
canvas,
progress,
video {
  display: inline-block; /* 1 */
  vertical-align: baseline; /* 2 */
}

/**
 * Prevent modern browsers from displaying `audio` without controls.
 * Remove excess height in iOS 5 devices.
 */

audio:not([controls]) {
  display: none;
  height: 0;
}

/**
 * Address `[hidden]` styling not present in IE 8/9/10.
 * Hide the `template` element in IE 8/9/10/11, Safari, and Firefox < 22.
 */

[hidden],
template {
  display: none;
}

/* Links
   ========================================================================== */

/**
 * Remove the gray background color from active links in IE 10.
 */

a {
  background-color: transparent;
}

/**
 * Improve readability of focused elements when they are also in an
 * active/hover state.
 */

a:active,
a:hover {
  outline: 0;
}

/* Text-level semantics
   ========================================================================== */

/**
 * Address styling not present in IE 8/9/10/11, Safari, and Chrome.
 */

abbr[title] {
  border-bottom: 1px dotted;
}

/**
 * Address style set to `bolder` in Firefox 4+, Safari, and Chrome.
 */

b,
strong {
  font-weight: bold;
}

/**
 * Address styling not present in Safari and Chrome.
 */

dfn {
  font-style: italic;
}

/**
 * Address variable `h1` font-size and margin within `section` and `article`
 * contexts in Firefox 4+, Safari, and Chrome.
 */

h1 {
  font-size: 2em;
  margin: 0.67em 0;
}

/**
 * Address styling not present in IE 8/9.
 */

mark {
  background: #ff0;
  color: #000;
}

/**
 * Address inconsistent and variable font size in all browsers.
 */

small {
  font-size: 80%;
}

/**
 * Prevent `sub` and `sup` affecting `line-height` in all browsers.
 */

sub,
sup {
  font-size: 75%;
  line-height: 0;
  position: relative;
  vertical-align: baseline;
}

sup {
  top: -0.5em;
}

sub {
  bottom: -0.25em;
}

/* Embedded content
   ========================================================================== */

/**
 * Remove border when inside `a` element in IE 8/9/10.
 */

img {
  border: 0;
}

/**
 * Correct overflow not hidden in IE 9/10/11.
 */

svg:not(:root) {
  overflow: hidden;
}

/* Grouping content
   ========================================================================== */

/**
 * Address margin not present in IE 8/9 and Safari.
 */

figure {
  margin: 1em 40px;
}

/**
 * Address differences between Firefox and other browsers.
 */

hr {
  box-sizing: content-box;
  height: 0;
}

/**
 * Contain overflow in all browsers.
 */

pre {
  overflow: auto;
}

/**
 * Address odd `em`-unit font size rendering in all browsers.
 */

code,
kbd,
pre,
samp {
  font-family: monospace, monospace;
  font-size: 1em;
}

/* Forms
   ========================================================================== */

/**
 * Known limitation: by default, Chrome and Safari on OS X allow very limited
 * styling of `select`, unless a `border` property is set.
 */

/**
 * 1. Correct color not being inherited.
 *    Known issue: affects color of disabled elements.
 * 2. Correct font properties not being inherited.
 * 3. Address margins set differently in Firefox 4+, Safari, and Chrome.
 */

button,
input,
optgroup,
select,
textarea {
  color: inherit; /* 1 */
  font: inherit; /* 2 */
  margin: 0; /* 3 */
}

/**
 * Address `overflow` set to `hidden` in IE 8/9/10/11.
 */

button {
  overflow: visible;
}

/**
 * Address inconsistent `text-transform` inheritance for `button` and `select`.
 * All other form control elements do not inherit `text-transform` values.
 * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera.
 * Correct `select` style inheritance in Firefox.
 */

button,
select {
  text-transform: none;
}

/**
 * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
 *    and `video` controls.
 * 2. Correct inability to style clickable `input` types in iOS.
 * 3. Improve usability and consistency of cursor style between image-type
 *    `input` and others.
 */

button,
html input[type="button"], /* 1 */
input[type="reset"],
input[type="submit"] {
  -webkit-appearance: button; /* 2 */
  cursor: pointer; /* 3 */
}

/**
 * Re-set default cursor for disabled elements.
 */

button[disabled],
html input[disabled] {
  cursor: default;
}

/**
 * Remove inner padding and border in Firefox 4+.
 */

button::-moz-focus-inner,
input::-moz-focus-inner {
  border: 0;
  padding: 0;
}

/**
 * Address Firefox 4+ setting `line-height` on `input` using `!important` in
 * the UA stylesheet.
 */

input {
  line-height: normal;
}

/**
 * It's recommended that you don't attempt to style these elements.
 * Firefox's implementation doesn't respect box-sizing, padding, or width.
 *
 * 1. Address box sizing set to `content-box` in IE 8/9/10.
 * 2. Remove excess padding in IE 8/9/10.
 */

input[type="checkbox"],
input[type="radio"] {
  box-sizing: border-box; /* 1 */
  padding: 0; /* 2 */
}

/**
 * Fix the cursor style for Chrome's increment/decrement buttons. For certain
 * `font-size` values of the `input`, it causes the cursor style of the
 * decrement button to change from `default` to `text`.
 */

input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
  height: auto;
}

/**
 * 1. Address `appearance` set to `searchfield` in Safari and Chrome.
 * 2. Address `box-sizing` set to `border-box` in Safari and Chrome.
 */

input[type="search"] {
  -webkit-appearance: textfield; /* 1 */
  box-sizing: content-box; /* 2 */
}

/**
 * Remove inner padding and search cancel button in Safari and Chrome on OS X.
 * Safari (but not Chrome) clips the cancel button when the search input has
 * padding (and `textfield` appearance).
 */

input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-decoration {
  -webkit-appearance: none;
}

/**
 * Define consistent border, margin, and padding.
 */

fieldset {
  border: 1px solid #c0c0c0;
  margin: 0 2px;
  padding: 0.35em 0.625em 0.75em;
}

/**
 * 1. Correct `color` not being inherited in IE 8/9/10/11.
 * 2. Remove padding so people aren't caught out if they zero out fieldsets.
 */

legend {
  border: 0; /* 1 */
  padding: 0; /* 2 */
}

/**
 * Remove default vertical scrollbar in IE 8/9/10/11.
 */

textarea {
  overflow: auto;
}

/**
 * Don't inherit the `font-weight` (applied by a rule above).
 * NOTE: the default cannot safely be changed in Chrome and Safari on OS X.
 */

optgroup {
  font-weight: bold;
}

/* Tables
   ========================================================================== */

/**
 * Remove most spacing between table cells.
 */

table {
  border-collapse: collapse;
  border-spacing: 0;
}

td,
th {
  padding: 0;
}


code:
build
composer.lock
vendor
.idea/

path: .gitignore

license: mit


code:
YiiBoard
========
[![Latest Version](https://img.shields.io/github/release/hosannahighertech/yii2-yiiboard.svg?style=flat-square)](https://github.com/hosannahighertech/yii2-yiiboard/releases)
[![Software License](https://img.shields.io/badge/license-Apache_2.0-blue.svg?style=flat-square)](LICENSE.md)
[![Build Status](https://img.shields.io/travis/hosannahighertech/yii2-yiiboard.svg?style=flat-square)](https://travis-ci.org/hosannahighertech/yii2-yiiboard)
[![Coverage Status](https://img.shields.io/scrutinizer/coverage/g/hosannahighertech/yii2-yiiboard.svg?style=flat-square)](https://scrutinizer-ci.com/g/hosannahighertech/yii2-yiiboard/code-structure)
[![Quality Score](https://img.shields.io/scrutinizer/g/hosannahighertech/yii2-yiiboard.svg?style=flat-square)](https://scrutinizer-ci.com/g/hosannahighertech/yii2-yiiboard)
[![Total Downloads](https://img.shields.io/packagist/dt/hosannahighertech/yii2-yiiboard.svg?style=flat-square)](https://packagist.org/packages/hosannahighertech/yii2-yiiboard)

Yii forum Module
More are coming

License
=======
The project is under Apache License 2.0. Please see the [license file](LICENSE.md) for details.


code:
<?php

namespace app\modules\yboard;

use app\components\ckeditor\CKEditorAsset;
use Yii;
use app\modules\yboard\YBoard;
use app\modules\yboard\models\YBoardSpider;
use app\modules\yboard\models\YBoardSession;
use app\modules\yboard\models\YBoardMember;
use app\modules\yboard\models\YBoardPost;
use app\modules\yboard\models\YBoardTopic;
use app\modules\yboard\models\YBoardMessage;
use app\modules\yboard\models\YBoardBan;

use yii\base\Event;
use yii\web\ErrorHandler;

class YBoard extends \yii\base\Module
{
    private $bundleInstance = null; //assets bundle

    public $controllerNamespace = 'app\modules\yboard\controllers';

    public $defaultRoute = 'forum';
    public $css = ['forum.css'];
    public $version = '0.2.0';
	public $forumTitle = 'Hosanna Forums - Home of Programmers';

    public $userClass = 'User';
    public $profile = ['edit'=>'', 'view'=>''];
    //Columns essential
	public $userIdColumn = 'id';
	public $userNameColumn = 'username';

    //additional but important
	public $birthdateColumn = null;
	public $genderColumn = null;
	public $regdateColumn = null;

	public $userMailColumn = false;
	public $topicsPerPage = 10;
	public $postsPerPage = 5;
	public $onlineLimit = 20;//900000; //milliseconds [15min]

	public $view = null;

    public $layout='forum';

    public function init()
    {
        parent::init();
        Yii::setAlias('yboard', dirname(dirname(__DIR__)).'/modules/yboard/');

        // initialize the module with the configuration loaded from config.php
        Yii::configure($this, require(__DIR__ . '/config.php'));

        //configure widgets
        Yii::$container->set('yii\widgets\LinkPager', [
            'options'=>[
                'class'=>'pagination ',
            ],
            'activePageCssClass'=>'pager-active ',
            'disabledPageCssClass'=>'pager-disabled ',
            'nextPageCssClass'=>'pager-next',
            'prevPageCssClass'=>'pager-prev',
        ]);

        //override default error handler
        $handler = new ErrorHandler(['errorAction' => $this->id.'/forum/error']);
        Yii::$app->set('errorHandler', $handler);
        $handler->register();
        //reload assets
        Yii::$app->assetManager->forceCopy = true;
        $this->registerAssets();
        //register translation cat
        $this->registerTranslations();

    }

    public function beforeAction($action)
    {
        if (parent::beforeAction($action)) {

            //update users online information
            $this->updateOnlineStatus($action);

            // register visit by webspider
            if(isset($_SERVER['HTTP_USER_AGENT'])) {
                $spider = YBoardSpider::find()
                    ->where(['user_agent'=>$_SERVER['HTTP_USER_AGENT']])
                    ->one();
            } else {
                $spider = null;
            }

            if($spider != null) {
                $spider->setScenario('visit');
                $spider->hits++;
                $spider->last_visit = null;
                $spider->save();
            }

            //menu fixed for Views
            $approvals1 = YBoardPost::find()
                ->unapprovedScope()
                ->count();
            $approvals2 = YBoardTopic::find()
                ->andWhere(['approved' => 0])
                ->count();

            $reports = YBoardMessage::find()
                ->reportScope()
                ->unreadScope()
                ->count();

            $this->params['foroMenu'] = [
                ['label'=>Yii::t('app', 'Members'), 'url'=>['member/index']],
                ['label'=>Yii::t('app', 'Pending'). ' (' . ($approvals1+$approvals2) . ')', 'url'=>['moderator/approve'], 'visible'=>Yii::$app->user->can('moderator')],
                ['label'=>Yii::t('app', 'Reported'). ' (' . $reports . ')', 'url'=>['moderator/reported'], 'visible'=>Yii::$app->user->can('moderator')],
            ];

			return true;
		}
		else
			return false;
    }

    public function registerAssets()
    {
        $view = Yii::$app->view;
        //Yii::$app->assetManager->forceCopy = true;
        $this->bundleInstance = \app\modules\yboard\YBoardAsset::register($view);
        $this->bundleInstance->css = $this->css;
        $this->bundleInstance->publish(Yii::$app->assetManager);
 	}

    public function updateOnlineStatus($action)
    {
        //Timed JS function
        //fetch current user list and add update statistics
        //js to Update it for a time
        session_start();
        Yii::$app->view->registerJs("
            function updateOnlineUsers() {
                $.get('".Yii::$app->urlManager->createUrl([$this->id.'/member/update-online-status', 'id'=>Yii::$app->session->id, 'uid'=>Yii::$app->user->id])."')
                .done(function(data){
                    data = $.parseJSON(data);
                    console.log(data);
                    $('#online-record').html(data.message);
                });
            }

            //call function to immediately update online users
            updateOnlineUsers();
            setInterval(updateOnlineUsers,".($this->onlineLimit*1000).");
        ");

        // register visit by guest or user
        /*$session = YBoardSession::findOne(Yii::$app->session->id);
        if($session == null) {
            $session = new YBoardSession;
            $session->setAttributes([
                'id'=>Yii::$app->session->id,
                'user_id'=>Yii::$app->user->isGuest ? NULL : Yii::$app->user->id,
                'last_visit'=>time(),
            ]);
        }
        $session->save() ;  */
        // register last visit by member
        if(!Yii::$app->user->isGuest)
        {
            $model = YBoardMember::findOne(Yii::$app->user->id);
            if($model !== null) {
                $model->last_visit 	= date('Y-m-d H:i:s');
                $model->save();

                //banned user are not allowed to access anything. So they have to be guests
                if($action->id!='banned' &&  $action->id!='error')
                {
                    Event::on(self::className(), self::EVENT_AFTER_ACTION, function ($event) {
                                //if banned redirect to banned

                        $ban = YBoardBan::find()
                            ->where(['user_id'=>Yii::$app->user->id])
                            ->orWhere(['ip'=>Yii::$app->request->userIP])
                            ->andWhere('expires>'.time())
                            ->one();
                        if($ban!=null)
                        {
                            return Yii::$app->response->redirect([$this->id.'/forum/banned', 'id' => $ban->id])->send();
                        }
                    });
                }

            } else {
                //redirect to associate member with forum account
                //no user can access forum logged in without member account
                if($action->id!='banned' &&  $action->id!='error' && $action->id!='associate' && $action->controller->id!='member')
                {
                    Event::on(self::className(), self::EVENT_AFTER_ACTION, function ($event)
                    {
                        return Yii::$app->response->redirect([$this->id.'/member/associate', 'id' => Yii::$app->user->id])->send();
                    });
                }
            }
        }
    }

    public function getGroupImages()
    {
        $data = [];
        //get all image files with a .jpg extension.
        $images = glob($this->bundleInstance->basePath.'/images/groups/' . "*.png");
        foreach($images as $image)
        {
          $data[basename($image)] =  basename($image);
        }
        return $data;
    }

    public function getRegisteredImage($filename) {
        return $this->bundleInstance->baseUrl .'/images/'. $filename;
    }

    public function getSocialImage($filename) {
        return $this->bundleInstance->baseUrl .'/images/social/'. $filename;
    }

    public function registerTranslations()
    {
        Yii::$app->i18n->translations['@yboard/*'] = [
            'class' => 'yii\i18n\PhpMessageSource',
            'sourceLanguage' => 'en-US',
            'basePath' => '@yboard/messages',
            'fileMap' => [
                '@yboard/yboard' => 'validation.php',
            ],
        ];
    }

    public static function t($category, $message, $params = [], $language = null)
    {
        return Yii::t('@yboard/' . $category, $message, $params, $language);
    }
}


code:
<?php

namespace app\modules\yboard;

class YBoardAsset extends \yii\web\AssetBundle
{
    public $sourcePath = '@yboard/assets';
    public $css = [];
    public $js = [
        'js/yboard.js',
    ];

    public $jsOptions = [
        'position' => \yii\web\View::POS_HEAD
    ];

    public $depends = [
        'yii\web\YiiAsset',
        'yii\bootstrap\BootstrapAsset',
        'app\assets\HighlighterAsset',
    ];
}


code:
<?php

namespace app\modules\yboard;

class YBoardSettingsAsset extends \yii\web\AssetBundle
{
    public $sourcePath = '@yboard/assets';
   /* public $css = [
        'css/base/forum.css',
    ]; */
    public $js = [
        'js/yboard.js',
        'js/ybsetting.js',
    ];

    public $jsOptions = [
        'position' => \yii\web\View::POS_HEAD
    ];

    public $depends = [
        'yii\web\YiiAsset',
        //'yii\bootstrap\BootstrapAsset',
    ];
}