import StatefulComponent from "./state/statefulComponent";
import {
ACTIONS as actions,
cloneData,
DIVIDER,
SPECIAL_DATA_KEYS,
} from "./utils/reducerUtils";
import { reducers } from "./state/genmoReducers";
import { linkFilter } from "./utils/conditionalFilters";
import {
InvalidLinkError,
InvalidStoryError,
LinkNotFoundError,
PassageNotFoundError,
} from "./utils/errors";
import { getDataHelpers, getPassageHelpers } from "./utils/handlebarsHelpers";
import Handlebars from "handlebars";
/**
* @typedef {String} Pid Number string identifier for a passage
*/
/**
* @typedef {Object} Link
* @property {String} name The display text for the link
* @property {String} link Name of the passage this link links to
* @property {Pid} pid
*/
/**
* @typedef {Object} Prompt
* @property {String} key
* @property {Boolean} complete
*/
/**
* @typedef {Object} Passage
* @property {String} text Full text for the passage
* @property {String} passageText The main text for the passage
* @property {Link[]} links
* @property {String} name
* @property {Pid} pid
* @property {Object} position
* @property {String} position.x
* @property {String} position.y
* @property {Prompt[]} [needsPrompt]
*/
/**
* @typedef {Object} StoryData
* @property {Passage[]} passages
* @property {String} name
* @property {Pid} startnode
* @property {String} creator
* @property {String} creator-version
* @property {String} ifid
*/
/**
* @typedef {Object} GenmoOptions
* @property {Function} outputFunction
* @property {Function} errorFunction
*/
/**
* @class
* @property {Function} outputFunction
* @property {Function} errorFunction
* @extends {StatefulComponent}
*
* @description Creates a Genmo Object based on `storyData`, a JSON object created using the [Twison](https://github.com/lazerwalker/twison) format in [Twine](https://twinery.org/)
*/
export class Genmo extends StatefulComponent {
/**
* @param {StoryData} storyData
* @param {GenmoOptions} opts
* @throws {InvalidStoryError}
*/
constructor(storyData, opts = {}) {
super(
{
storyData,
currentPassage: null,
data: {
inventory: {},
},
},
reducers
);
if (!storyData || !storyData.passages || !storyData.startnode) {
throw new InvalidStoryError(`storyData given to Genmo is invalid.`);
}
this.outputFunction =
opts.outputFunction || (console && console.log) || this.noop;
this.errorFunction =
opts.errorFunction || (console && console.warn) || this.noop;
this.customHelpers = {};
if (opts.customHelpers) {
Object.entries(opts.customHelpers).forEach(([helperName, helperFn]) => {
this.addHelper(helperName, helperFn);
});
}
this.followLink(storyData.startnode);
}
/**
* Calls the provided `outputFunction` during construction with the current passage.
* If `outputFunction` returned something, this returns that as well.
*
* @return {any}
*/
outputCurrentPassage() {
return this.outputFunction(this.getCurrentPassage());
}
/**
* Returns current passage. This function also appends `passageText` (with data and helpers replaced), and filters links
* @return {Passage}
*/
getCurrentPassage() {
return this.getPassage(this.state.currentPassage);
}
/**
* Returns the passage indicated by passageOrPid, with `passageText` set and `passage.link` properly filtered.
* Returns null if passage is not found.
*
* @param {Passage|Pid} passageOrPid
* @returns {Passage}
*/
getPassage(passageOrPid) {
const pid = passageOrPid.pid ? passageOrPid.pid : passageOrPid;
if (!pid) return null;
const passage = {
...(this.state.storyData.passages.find(
(passage) => passage.pid === pid
) || {}),
};
if (!Object.keys(passage).length) return null;
passage.passageText = this.getPassageText(passage);
passage.links = (passage.links || [])
.map((link) => linkFilter(link, this.state.data))
.filter((l) => l);
return passage;
}
/**
* Returns a passage with the `name` property set to `name`, or null if it doesn't exist.
*
* @param {String} name
* @returns {Passage}
*/
getPassageByName(name) {
const passage = {
...this.state.storyData.passages.find((passage) => passage.name === name),
};
return this.getPassage(passage);
}
/**
* Returns whether this passage is valid or not. A valid passage is an object that has a key `pid` that matches an object in `state.storyData.passages`
*
* @param {Passage|null} passage
* @returns {Boolean}
*/
isValidPassage(passage) {
if (!passage) return false;
return Boolean(
this.state.storyData.passages.find((p) => p.pid === passage.pid)
);
}
/**
* Splits up the passage based on `DIVIDER`
* If `passage` is not specified, `currentPassage` is used instead.
* @param {Passage|null} passage
* @throws {PassageNotFoundError}
* @ignore
*/
splitPassage(passage) {
const targetPassage = this.isValidPassage(passage)
? passage
: this.getCurrentPassage();
if (!targetPassage)
this.onError(new PassageNotFoundError({ pid: (passage || {}).pid }));
return targetPassage.text.split(DIVIDER);
}
/**
* Returns just the text of the passage, with variables replaced and helpers processed.
* If `passage` is not specified, `currentPassage` is used instead.
*
* @param {Passage|null} passage
* @return {String}
*/
getPassageText(passage) {
const parts = this.splitPassage(passage);
if (!parts) return null;
const text = Handlebars.create().compile(parts[0])(this.state.data, {
helpers: getPassageHelpers(this, this.customHelpers),
});
return text;
}
/**
* Returns the data object associated with this passage, if it exists.
* If `passage` is not specified, `currentPassage` is used instead.
*
* @param {Passage|null} passage
* @return {Object|null}
*/
getRawPassageData(passage) {
const parts = this.splitPassage(passage);
if (!parts) return null;
const json = parts.length === 3 ? parts[2] : null;
let parsed = null;
try {
parsed = JSON.parse(json);
} catch (e) {
// That wasn't JSON we just parsed, oh well.
}
// Also get data on the passage set by Mustache
const handleBarsData = {
...(parsed || {}),
};
Handlebars.create().compile(parts[0])(this.state.data, {
helpers: getDataHelpers(handleBarsData, this.state.currentPassage),
});
if (Object.keys(handleBarsData).length) {
return handleBarsData;
}
return parsed;
}
/**
* Gets the `passage_data` object for the current passage, or an empty Object if it isn't set.
* If `passage` is not specified, `currentPassage` is used instead.
*
* @param {Passage|null} passage
* @returns {Object}
*/
getPassageData(passage) {
return (
(this.getRawPassageData(passage) || {})[SPECIAL_DATA_KEYS.PASSAGE_DATA] ||
{}
);
}
/**
* Merges `data` with Genmo's `state.data`
* @param {Object} data
*/
setData(data) {
if (!(typeof data === "object")) {
return false;
}
this.doAction({
...actions.SET_DATA,
data,
});
}
/**
* Returns Genmo's custom data
* @return {Object}
*/
getData() {
return { ...this.state.data };
}
/**
* Follows the given link or pid to the next passage.
*
* The link must exist on the current passage (`getCurrentPassage()`). Otherwise an error will be sent to `errorFunction`.
* An error will also be sent if the linked to passage doesn't exist.
*
* @param {Passage|Pid} link
* @param {Function} [callback]
* @param {...any} [callbackArgs]
* @throws {InvalidLinkError}
* @throws {LinkNotFoundError}
* @throws {PassageNotFoundError}
*/
followLink(link, callback, ...callbackArgs) {
if (!link) {
return this.errorFunction(
new InvalidLinkError({
link,
})
);
}
let pid = link;
if (link.hasOwnProperty("pid")) {
pid = link.pid;
}
const storyIsStarting =
pid === this.state.storyData.startnode &&
this.state.currentPassage === null;
const activeLink =
storyIsStarting ||
(this.state.currentPassage.links || []).find((l) => l.pid === pid);
if (!activeLink) {
return this.errorFunction(
new LinkNotFoundError({
link,
})
);
}
const nextPassage = this.state.storyData.passages.find(
(p) => p.pid === pid
);
if (!nextPassage) {
return this.errorFunction(
new PassageNotFoundError({
pid,
})
);
}
this.doAction(
{
...actions.FOLLOW_LINK,
link: activeLink,
nextPassage,
},
callback,
...callbackArgs
);
}
/**
* When a passage requires a prompt, this function will add the response to the story's `state.data`
*
* @param {String} response
* @param {Function} [callback]
* @param {...any} [callbackArgs]
*/
respondToPrompt(response, callback, ...callbackArgs) {
const responseEntries = Object.entries(response);
const [key, value] = (() => {
if (responseEntries.length) {
return responseEntries[0];
}
return [null, null];
})();
this.doAction(
{
...actions.PROMPT_ANSWER,
key,
value,
pid: this.state.currentPassage.pid,
},
callback,
...callbackArgs
);
}
/**
* Returns the inventory
* @return {Object}
*/
getInventory() {
return this.state.data[SPECIAL_DATA_KEYS.INVENTORY];
}
/**
*
* @param {String} item
* @return {Number|null} the quantity of `item`, null if the item isn't in the inventory at all.
*/
getItem(item) {
return (this.getInventory() || {})[item] || null;
}
/**
* Returns whether the item a) exists in the inventory and b) the player has more than 0 of that item
* @param {String} item
* @return {Boolean}
*/
hasItem(item) {
return Boolean(this.getItem(item));
}
/**
* Directly adds quantities of items to the inventory. The items object uses the item name as key, and the quantity change as value.
*
* @example
* const newItems = {
* toy: 1,
* coin: -2,
* }
* updateInventory(newItems);
* // This will add one `toy` and remove 2 `coins`
* @param {Object} items
*/
updateInventory(items) {
this.doAction({
...actions.UPDATE_INVENTORY,
items,
});
}
/**
*
* @param {String} item
*/
addInventory(item) {
this.updateInventory({
[item]: 1,
});
}
/**
*
* @param {String} item
*/
removeInventory(item) {
this.updateInventory({
[item]: -1,
});
}
/**
* Returns a function that wraps the helper function, forwarding Handlebars'
* `options` object (as `handlebarsOptions`) and passing a second argument,
* an object containing `genmo` which refers to this instance.
*
* @param {Function} helperFn
* @returns Function
*/
helperWrapper(helperFn) {
return (handlebarsOptions) => helperFn(handlebarsOptions, { genmo: this });
}
/**
* Adds a Handlebars helper with the name `helperName`.
* If a helper already existed at `helperName`, it is overwritten.
*
* @param {String} helperName
* @param {Function} helperFn
*/
addHelper(helperName, helperFn) {
this.customHelpers[helperName] = this.helperWrapper(helperFn);
}
/**
* Removes a Handlebars helpers with the name `helperName`,
* if it exists.
*
* @param {String} helperName
*/
removeHelper(helperName) {
if (this.customHelpers[helperName]) {
delete this.customHelpers[helperName];
}
}
/**
* Handles the error passed in. Default is to call `errorFunction`
*
* @ignore
* @param {Error} err
*/
onError(err) {
this.errorFunction(err);
}
/**
* A noop function as a placeholder for outputFunction / errorFunction
* @ignore
*/
noop() {}
}
export * as ERRORS from "./utils/errors";