Pre-release
AdventureJS Docs Downloads
Score: 0 Moves: 0
// Goalcard.js
(function () {
  /* global adventurejs A */

  /**
   * @class adventurejs.Goalcard
   * @ajsinternal
   * @param {Game} game A reference to the game instance.
   * @ajsnavheading FrameworkClasses
   * @summary Manages goals for a {@link adventurejs.Game|Game} instance.
   * @classdesc
   * <p>
   * <strong>Goalcard</strong> is a repository for player goals.
   * Goalcard is created automatically
   * by {@link adventurejs.Game|Game}. This is an internal class
   * that authors should not need to construct. However,
   * authors can set goal options from their game file as
   * shown below.
   * </p>
   * <h3 class="examples">Example:</h3>
   * <pre class="display"><code class="language-javascript">var MyGame = new adventurejs.Game( "MyGame", "GameDisplay" );
   * MyGame.Goalcard.set({
   *
   * });
   * </code></pre>
   */
  class Goalcard {
    constructor(game) {
      this.game = game;
      this.game.world._goalcard = {};
      this.introduction = "";
      this.goals = {};
      this.show_satisfied_goals = true;
    }

    /**
     * Activate a goal, with optional recurse.
     * @method adventurejs.Goalcard#activateGoal
     * @param {String} key A name or id to search for.
     * @param {Boolean} recurse Whether to activate recursively.
     * @returns {}
     */
    activateGoal(key, recurse = true) {
      return this.toggleGoal(key, true, recurse);
    }

    /**
     * Complete a goal, with optional recurse.
     * @method adventurejs.Goalcard#completeGoal
     * @param {String} key A name or id to search for.
     * @param {Boolean} recurse Whether to complete recursively.
     * @returns {}
     */
    completeGoal(key, recurse = true) {
      let goal = this.findGoal(key);
      if (!goal) return;
      goal.complete = true;
      if (
        recurse &&
        "object" === typeof goal.goals &&
        Object.keys(goal.goals).length
      ) {
        for (let subkey in goal.goals) {
          this.completeGoal(subkey, recurse);
        }
      }
    }

    /**
     * Get a count of the number of goals in each group.
     * @method adventurejs.Goalcard#countGoals
     */
    countGoals(node = this) {
      if (
        "undefined" === typeof node.goals ||
        Object.keys(node.goals).length === 0
      ) {
        return [node.active ? 0 : 1, 1];
      }
      let count = 0;
      let unrevealed = 0;
      for (const key in node.goals) {
        // total += this.countGoals(node.goals[key]);
        let [u, t] = this.countGoals(node.goals[key]);
        unrevealed += u;
        count += t;
      }
      node.unrevealed = unrevealed;
      node.count = count;
      return [unrevealed, count];
    }

    /**
     * Create a new goal.
     * @method adventurejs.Goalcard#createGoal
     * @returns {String}
     */
    createGoal() {
      let goal = {
        key: "",
        text: "",
        name: "",
        active: true,
        complete: false,
        complete_text: "",
        parent: null,
        goals: {},
      };
      return goal;
    }

    /**
     * Deactivate a goal, with optional recurse.
     * @method adventurejs.Goalcard#deactivateGoal
     * @param {String} key A name or id to search for.
     * @returns {}
     */
    deactivateGoal(key, recurse = true) {
      return this.toggleGoal(key, false, recurse);
    }

    /**
     * Recursively find goal by name or key.
     * @method adventurejs.Scorecard#findGoal
     * @param {String} key A name or id to search for.
     * @param {Object} root The root object level to begin search.
     * @returns {Object|null}
     */
    findGoal(key) {
      return this.findGoalByName(key) || this.findGoalByKey(key);
    }

    /**
     * Find goal by key. Looks recursively.
     * @method adventurejs.Goalcard#findGoalByKey
     * @param {String} key An object key to search for.
     * @param {Object} root The root object level to begin search.
     * @returns {Object|null}
     */
    findGoalByKey(key, root = this) {
      if (
        !key ||
        !root ||
        typeof root !== "object" ||
        !root.goals ||
        typeof root.goals !== "object"
      )
        return null;

      if (root.goals && Object.prototype.hasOwnProperty.call(root.goals, key)) {
        return root.goals[key];
      }

      if (root.goals) {
        for (const k of Object.keys(root.goals)) {
          const found = this.findGoalByKey(key, root.goals[k]);
          if (found) return found;
        }
      }

      return null;
    }

    /**
     * Recursively find goal by its name.
     * @method adventurejs.Scorecard#findGoalByName
     * @param {String} key An object key to search for.
     * @param {Object} root The root object level to begin search.
     * @returns {Object|null}
     */
    findGoalByName(name, root = this) {
      if (
        !name ||
        !root ||
        typeof root !== "object" ||
        !root.goals ||
        typeof root.goals !== "object"
      )
        return null;
      const comparator = this.game.settings.obfuscate_goals
        ? A.obfuscate(name)
        : name;
      for (let key in root.goals) {
        let goal = root.goals[key];
        if (goal.name === comparator) {
          return goal;
        }
        if (goal.goals) {
          let subgoal = this.findGoalByName(name, goal);
          if (subgoal) return subgoal;
        }
      }
      return null;
    }

    /**
     * Get a printable list of goals.
     * @method adventurejs.Scorecard#listGoals
     */
    listGoals(goals = this.goals) {
      let msg = "";
      for (let key in goals) {
        const goal = goals[key];
        if (!goal.active) continue;
        let li = "";
        let complete = "";

        if (goal.complete) {
          if (this.game.settings.show_completed_goals) {
            li = goal.complete_text || goal.text;
            complete = "ajs-completed-goal";
          }
        } else {
          li = goal.text;
        }

        if ("object" === typeof goal.goals && Object.keys(goal.goals).length) {
          li += this.listGoals(goal.goals);
        }

        if (li) msg += `<li class="ajs-li ajs-goal ${complete}">${li}</li>`;
      }

      if (msg) msg = `<ul class="ajs-ul">${msg}</ul>`;

      return msg;
    }

    /**
     * Process goal data in longhand format.
     * @method adventurejs.Goalcard#processLonghand
     */
    processLonghand(source, dest = this.goals, prefix = "$") {
      let count = 0;
      for (let srckey in source) {
        count++;
        const srcgoal = source[srckey];

        // create a new empty point
        const destkey = `${prefix}${count}`;
        dest[destkey] = dest[destkey] || this.createGoal();
        const goal = dest[destkey];
        goal.key = destkey;

        if ("string" === typeof srcgoal) {
          goal.text = srcgoal;
        } else if ("object" === typeof srcgoal && Object.keys(srcgoal).length) {
          for (let prop in srcgoal) {
            if (prop !== "goals") {
              goal[prop] = srcgoal[prop];
            }
          }
          goal.name = srckey;
          if (srcgoal.goals && Object.keys(srcgoal.goals).length) {
            this.processLonghand(srcgoal.goals, goal.goals, destkey + "|");
          }
        }
      } // for key in goals
      return count;
    }

    /**
     * Process goal data in shorthand format.
     * @method adventurejs.Goalcard#processShorthand
     */
    processShorthand(goals) {
      // break shortcut format into lines
      const lines = String(goals)
        .trim()
        .split("\n")
        .map((line) => line.trim())
        .filter(Boolean);

      // ancestors[level] is the most recent group node for that header level.
      // ancestors[0] is the root container.
      const ancestors = [];
      ancestors[0] = this;

      let count = 0;
      for (let line of lines) {
        count++;
        let active = false;

        let re = /\s*\|\s*\+$/;
        if (re.test(line)) {
          line = line.replace(re, "");
          active = true;
        }

        re = /\s*\|\s*x$/;
        if (re.test(line)) {
          line = line.replace(re, "");
          active = false;
        }

        // separate # indicators from text
        let line_content = line.match(/^(#{1,6})?\s*(.*)$/);
        if (!line_content) continue;

        // get level // 1 => "#", 2 => "##", 3 => "###", ...
        // if none set, default to 1
        const level = line_content[1] ? line_content[1].length : 1;

        // get body of line
        let line_body = line_content[2].trim();

        // remove leading dash
        const is_sub = line_body.startsWith("-");
        if (is_sub) line_body = line_body.replace(/^-\s*/, "").trim();

        // find nearest group at level-1 or above
        // (should exist at ancestors[level-1])
        const node = ancestors[level - 1] || ancestors[0];
        node.goals = node.goals || {};

        let [name, text, complete_text] = line_body.split(/\s*\|\s*/);
        if (!name) continue;
        if (!text) text = name;

        let target, parent, id;

        if (is_sub) {
          // Attach item under the node at this exact header level if it exists;
          // otherwise attach to the node (level-1 (ancestors[level]
          // will exist if a node line with this level appeared)
          const groupAtLevel = ancestors[level];
          const attachTo =
            groupAtLevel && groupAtLevel.goals ? groupAtLevel : node;
          parent = attachTo;
          attachTo.goals = attachTo.goals || {};

          // attachTo.goals[count] = this.createGoal();
          // target = attachTo.goals[count];

          // use the count of the hint as its key
          let nodecount = Object.keys(attachTo.goals).length + 1;
          id = `${attachTo.key ? attachTo.key + "|" : "$"}${nodecount}`;

          attachTo.goals[id] = this.createGoal();
          target = attachTo.goals[id];
        } else {
          // create or reuse group node at this level
          let nodecount = Object.keys(node.goals).length + 1;
          id = `${node.key ? node.key + "|" : "$"}${nodecount}`;

          // node.goals[count] = this.createGoal();
          // target = node.goals[count];
          node.goals[id] = this.createGoal();
          target = node.goals[id];
          ancestors[level] = target;

          // clear any deeper-level ancestors
          // (so later siblings won't incorrectly attach)
          for (let k = level + 1; k < ancestors.length; k++)
            ancestors[k] = undefined;
        }

        target.active = active;
        target.name = this.game.settings.obfuscate_goals
          ? A.obfuscate(name)
          : name;
        target.text = this.game.settings.obfuscate_goals
          ? A.obfuscate(text)
          : text;
        if (complete_text) {
          target.complete_text = this.game.settings.obfuscate_goals
            ? A.obfuscate(complete_text)
            : complete_text;
        }
        // target.key = count;
        target.key = id;
        if (parent) target.parent = parent;
        // count++;
      }
    }

    /**
     * Provides a chainable shortcut method for setting a number of properties on the instance.
     * @method adventurejs.Goalcard#set
     * @param {Object} props A generic object containing properties to copy to the instance.
     * @returns {adventurejs.Goalcard} Returns the instance the method is called on (useful for chaining calls.)
     * @chainable
     */
    set(goalcard) {
      if (goalcard.goals) {
        // handle shortcut format
        if ("string" === typeof goalcard.goals) {
          this.processShorthand(goalcard.goals);
        }

        // handle full format
        else {
          this.processLonghand(goalcard.goals);
        }
        delete goalcard.goals;

        this.countGoals();

        // pass on any other properties - expected properties are
        for (let prop in goalcard) {
          this[prop] = goalcard[prop];
        }

        return this;
      }
    }

    test(path, delimiter = ",") {
      console.warn({ path });

      if (typeof path !== "string") return undefined;

      const parts = path
        .split(delimiter)
        .map((s) => A.serialize(s.trim()))
        .filter(Boolean);

      let current = this.goals;

      for (const part of parts) {
        if (!current) {
          console.warn("goal path not found");
          return undefined;
        }

        // Prefer nested "goals" object if it exists and contains the key
        if (
          current.goals &&
          Object.prototype.hasOwnProperty.call(current.goals, part)
        ) {
          current = current.goals[part];
        } else if (Object.prototype.hasOwnProperty.call(current, part)) {
          current = current[part];
        } else {
          // not found
          console.warn("goal path not found");
          return undefined;
        }
      }

      console.warn(current);
      return current;
    }

    /**
     * Toggle a goal's active state, with optional recurse.
     * @method adventurejs.Goalcard#toggleGoal
     * @param {String} key A name or id to search for.
     * @param {Boolean} bool Activate or deactivate.
     * @param {Boolean} recurse Whether to activate recursively.
     * @returns {}
     */
    toggleGoal(key, bool, recurse = true) {
      let goal = this.findGoal(key);
      if (!goal) return false;
      goal.active = bool;
      if (
        recurse &&
        "object" === typeof goal.goals &&
        Object.keys(goal.goals).length
      ) {
        for (let subkey in goal.goals) {
          this.toggleGoal(subkey, bool, recurse);
        }
      }
      return true;
    }
  }

  adventurejs.Goalcard = Goalcard;
})();