Home Reference Source

src/jg/model.js

/**
 * @module model
 * @memberof module:jumbogrove
 */
import _ from 'lodash';
import Character from './character';
import Situation from './situation';

/**
 * Maintains game state and allows you to make changes to it.
 * 
 * The model object is the primary way for you to interact with Jumbo Grove.
 */
export default class model {
    /**
     * @ignore
     * 
     * @param {object} args Arguments object
     * @param {object[]} args.characters
     * @param {object} args.globalState
     * @param {object[]} args.situations
     * @param {string} args.initialSituation
     */
    constructor(director, {characters, globalState, situations, initialSituation}) {
        /** @ignore */
        this._director = director;
        /** @ignore */
        this._characters = {};
        /** @ignore */
        this._situations = {};
        /** @ignore */
        this._initialSituationId = initialSituation;

        // These will be injected when the UI is bound to the director
        /** @ignore */
        this.navHeaderHTML = null;
        /** @ignore */
        this.asideHeaderHTML = null;

        /**
         * The situation currently being run, or last seen by the user.
         * @member
         * @type {Situation|null} */
        this.currentSituation = null;

        characters.forEach((c) => this._characters[c.id] = new Character(c));

        /**
         * Store all non-character game state here; **Must be JSON-safe!** You may mutate
         * this object freely as long as it is safe to convert it to JSON and back.
         * @member
         * 
         */
        this.globalState = _.cloneDeep(globalState);

        /**
         * The character with ID `'player'`.
         * @member
         * @type {Character|null} */
        this.player = this.character('player') || null;

        situations.forEach((s) => {
            if (this._situations[s.id]) throw new Error(`Duplicate situation id: ${s.id}`);
            this._situations[s.id] = new Situation(s);
        });

        /**
         * List of all characters in the game.
         * @member
         * @type {Character[]} */
        this.allCharacters = _.sortBy(Object.values(this._characters), ({priority}) => priority || 0);
    }

    /**
     * Follow a Jumbo Grove link (`@situation-id` or `>action`).
     * @param {command} string
     */
    do(...args) {
        return this._director.handleCommandString(...args);
    }

    /**
     * Go to the given sitaution (no `@`).
     * @param {string} id
     */
    goTo(...args) {
        return this._director.goTo(...args);
    }

    /**
     * Returns true iff the given string can be handled by Jumbo Grove (rather than being a normal HTML link)
     * @param {string} string A string to check
     * @returns {Boolean} 
     */
    isManagedLink(...args) {
        return this._director.isManagedLink(...args);
    }

    /**
     * Add arbitrary methods to the model object. Since the model is passed to
     * all callbacks, this is a good way to make convenient functions accessible.
     * 
     * Also, anything you pass here will also be provided to the template context.
     * 
     * @param {Map<string, function>} fns Mapping of name to function
     */
    extend(fns) {
        Object.assign(this, fns);
    }

    /**
     * @ignore
     */
    toSave() {
        return {
            globalState: this.globalState,
            currentSituationId: this.currentSituation ? this.currentSituation.id : null,
            characters: this.allCharacters.map((c) => c.toSave()),
            situations: Object.values(this._situations).map((s) => s.toSave()),
        };
    }

    /**
     * @ignore
     */
    loadSave(obj) {
        this.globalState = obj.globalState;
        this.currentSituation = this._situations[obj.currentSituationId] || null;
        for (const data of obj.characters) {
            this.character(data.id).loadSave(data);
        }
        for (const s of obj.situations) {
            this.situation(s.id).loadSave(s);
        }
    }

    /**
     * @ignore
     */
    toString() {
        return `Model(globalState=${this.globalState}, characters=${this.characters})`;
    }

    /**
     * Looks up a situation by ID. Prints an error to the console if there isn't one.
     * @param {string} id 
     * @returns {Situation|null} Situation with the given ID
     */
    situation(id) {
        if (!this._situations[id]) console.error(`Situation not found: ${id}`);
        return this._situations[id];
    }

    /**
     * Returns a list of all situations matching the given ID (`foo`) or tag (`#foo`).
     * @param {string} idOrTag 
     * @returns {Situation[]}
     */
    situations(idOrTag) {
        if (idOrTag.startsWith("#")) {
            const tag = idOrTag.slice(1);
            return Object.values(this._situations)
                .filter((s) => s.tags.indexOf(tag) !== -1);
        } else {
            return [this._situations[idOrTag]];
        }
    }

    /**
     * Look up a character by ID. Returns `undefined` if there isn't one.
     * @param {string} id 
     */
    character(id) {
        return this._characters[id];
    }

    /**
     * Return a random number 0-1. Currently this just calls `Math.random()`, but
     * in the future it might do something fancy with seeds that let you avoid
     * save scumming.
     */
    random() {
        return Math.random();
    }

    /**
     * Given a set of situations, do some smart stuff and return the situations
     * that match the filter.
     * 
     * 1. Filter out all situations for which `situation.getCanSee(model, model.currentSituation, situation)` returns `false`.
     * 2. Find the highest priority that matches a list of situations at least as big as `atLeast`.
     * 3. If there are more situations left than there are `atMost`, randomly remove some.
     * 4. Sort by `situation.displayOrder`.
     * 
     * Note that it is possible to end up with a list of situations for which `getCanChoose()` returned `false` for all of them!
     * 
     * This logic has been shamelessly stolen from Undum.
     * 
     * @param {string[]} arrayOfSituationIdsOrTags Like `['one-situation', '#situations-matching-this-tag']`
     * @param {number} atLeast 
     * @param {number} atMost 
     */
    interpretChoices(arrayOfSituationIdsOrTags, atLeast = 0, atMost = Number.MAX_VALUE) {
        const host = this.currentSituation;
        if (host.debugChoices) debugger;  // eslint-disable-line no-debugger
        const situations = [].concat.apply(
            [], arrayOfSituationIdsOrTags.map(this.situations.bind(this)));
        // remove invisible situations
        const visibleSituations = situations.filter((s) => s.getCanSee(this, host, s));

        // sort by display order
        const sortedSituations = _.sortBy(
            visibleSituations, (s) => s.getDisplayOrder(this, host));

        // index by priority; figure out what priorities are being used
        const sortedSituationsByPriority = {};
        const prioritiesSeen = [];
        for (const s of sortedSituations) {
            const p = s.getPriority(this, host);
            if (!sortedSituationsByPriority[p]) sortedSituationsByPriority[p] = [];
            sortedSituationsByPriority[p].push(s);
            prioritiesSeen.push(p);
        }

        // figure out what priority we want to use (only one!)
        let chosenPriority = Number.MAX_VALUE;
        for (const p of _.uniq(prioritiesSeen.sort().reverse())) {
            if (sortedSituationsByPriority[p].length >= atLeast) {
                chosenPriority = p;
                break;
            }
        }
        let chosenSituations = sortedSituationsByPriority[chosenPriority];
        if (!chosenSituations) {
            return [];  // Uh oh!
        }

        // Remove random array items until we are under the limit
        while (chosenSituations.length > atMost) {
            const i = Math.floor(this.random() * chosenSituations.length);
            chosenSituations.splice(i, 1);
        }

        // return the chosen situations and provide more info for each
        const allChoices = chosenSituations.map((s) => {
            return {
                situationId: s.id,
                text: s.getOptionText(this, host),
                isEnabled: s.getCanChoose(this, host),
            };
        });
        return allChoices.filter(({isEnabled}) => isEnabled).concat(allChoices.filter(({isEnabled}) => !isEnabled));
    }

    /**
     * Given an array of tags or situation IDs (can be both in the same array),
     * present the relevant choices in the transcript using the logic in
     * {@link model.interpretChoices}, then go to the situation chosen by the
     * player.
     * @param {string[]} arrayOfSituationIdsOrTags Array of strings containing either `#tags` or `situation-ids`.
     */
    presentChoices(arrayOfSituationIdsOrTags) {
        this._director.ui.presentChoices(arrayOfSituationIdsOrTags)
            .then(({situationId, itemId}) => {
                this.do(`@${situationId}`, itemId, 'fake');
            });
    }
}