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

  /**
   * @class adventurejs.Scorecard
   * @ajsinternal
   * @param {Game} game A reference to the game instance.
   * @ajsnavheading FrameworkClasses
   * @summary Manages score for a {@link adventurejs.Game|Game} instance.
   * @classdesc
   * <p>
   * <strong>Scorecard</strong> is a repository for game score
   * options. Scorecard is created automatically
   * by {@link adventurejs.Game|Game}. This is an internal class
   * that authors should not need to construct. However,
   * authors can set scoring options from their game file as
   * shown below, and call score updates from custom scripts.
   * </p>
   * <h3 class="examples">Example:</h3>
   * <pre class="display"><code class="language-javascript">var MyGame = new adventurejs.Game( "MyGame", "GameDisplay" );
   * MyGame.scorecard.set({
   *   points: {
   *     "unlock door": { value: 1, awarded: false, bonus: false, award_text: '', recorded: false },
   *     "unlock chest": { value: 1, awarded: false, bonus: false, award_text: '', recorded: false },
   *     "drink potion": { value: 1, awarded: false, bonus: false, award_text: '', recorded: false },
   *   }
   * });
   * </code></pre>
   */
  class Scorecard {
    constructor(game) {
      this.game = game;
      this.game.world._scorecard = {};

      /**
       * Used to keep track of player's current score.
       * @var {Boolean} adventurejs.Scorecard#score
       * @default 0
       */
      this.score = 0;

      /**
       * Used to compare old score vs new score during
       * score updates.
       * @var {Boolean} adventurejs.Scorecard#newscore
       * @default 0
       */
      this.newscore = 0;

      /**
       * Used to show the difference between old score and
       * new score during score updates.
       * @var {Boolean} adventurejs.Scorecard#diff
       * @default 0
       */
      this.diff = 0;

      /**
       * Can be set to print when player is awarded any point.
       * @var {Boolean} adventurejs.Scorecard#award_text
       * @default ""
       */
      this.award_text = "";

      /**
       * Can be used to set customized score printouts in
       * the game display. <code>score_format</code> is
       * subject to <code>getStringArrayFunction()</code>
       * which means that it can be set to a string or an
       * array or a function.
       * For example, this returns a string:
       * <pre class="display"><code class="language-javascript">award_text: `{Our} score went up! `,</code></pre>
       * This returns a custom function:
       * <pre class="display"><code class="language-javascript">award_text: function(){
       *   return `Dude, you totally just got ${this.diff} points!`
       * },</code></pre>
       * Or maybe you just want to tweak the score display.
       * By default score appears in 0/0 format, but let's
       * say you'd like it to say "Score: 0 out of 0".
       * <pre class="display"><code class="language-javascript">score_format: function()
       * {
       *   return `Score: ${this.score} out of ${this.total}`;
       * }</code></pre>
       *
       * @var {Boolean} adventurejs.Scorecard#score_format
       * @default {}
       */
      this.score_format = {};

      /**
       * Used to store the game's points.
       * @var {Boolean} adventurejs.Scorecard#points
       * @default {}
       */
      this.points = {};

      /**
       * If user has no points and types <i>points</i> print this.
       * @var {Boolean} adventurejs.Scorecard#noscore
       * @default {}
       */
      this.noscore = null;

      /**
       * If user types <i>points</i> print this as a leading overview.
       * @var {Boolean} adventurejs.Scorecard#overview
       * @default {}
       */
      this.overview = null;

      /**
       * summarize_updates determines how score updates
       * are printed. With summarize_updates set to true,
       * if multiple points occur in a turn, only
       * one score update will be printed, with the
       * cumulative score change. If summarize_updates are
       * false, each score update will be printed, and will
       * use unique award_text that may be provided.
       * @var {Boolean} adventurejs.Scorecard#summarize_updates
       * @default {}
       */
      this.summarize_updates = false;

      // set scorecard to listen for end of turn,
      // then check to see if score has changed,
      // and if it has, print score updates
      this.game.reactor.addEventListener("inputParseComplete", function (e) {
        this.game.scorecard.updateScore();
      });
    }

    /**
     * Print score updates in the aggregate.
     * @method adventurejs.Scorecard#summarizeUpdates
     */
    summarizeUpdates() {
      let msg = "";
      if (this.newscore !== this.score) {
        let direction = this.newscore > this.score ? "up" : "down";
        let diff = this.newscore - this.score;
        let s = Math.abs(diff) > 1 ? "s" : "";
        this.diff = diff;

        // the aggregate uses a single award_text setting
        msg = this.award_text
          ? A.getSAF.call(this.game, this.award_text, this)
          : `[ ** {Our} score went ${direction} by ${diff} point${s} ** ]`;

        msg = `<span class="ajs-p ajs-score-msg ${direction}">${msg}</span>`;
      }
      return msg;
    }

    /**
     * Mark the selected point as complete and update the score.
     * @method adventurejs.Scorecard#awardPoint
     * @param {String|Object} point An id string or object reference.
     * @param {Boolean} recurse Whether to award nested points.
     * @returns {Boolean}
     */
    awardPoint(key, recurse = true) {
      const point = this.getPoint(key);
      if (!point) return false;
      const input = this.game.getInput();

      point.awarded = true;
      this.game.world._scorecard[key] = true;
      if (!input.points.includes(key)) input.points.push(key);

      // is point a group?
      // if so, complete all sub-points
      if (point.points && Object.keys(point.points).length && recurse) {
        for (let nested_key in point.points) {
          this.awardPoint(point.points[nested_key]);
        }
      }

      if (null !== point.action) {
        let action = A.getSAF.call(this.game, point.action, this);
      }

      // is point a member of a group?
      if (point.parent?.points) {
        let count = 0;
        for (let childkey in point.parent.points) {
          let childpoint = point.parent.points[childkey];
          if (childpoint.awarded) count++;
        }
        // if so, and all siblings are complete, and parent incomplete, complete parent
        if (
          count === Object.keys(point.parent.points).length &&
          !point.parent.awarded
        ) {
          this.awardPoint(point.parent, false);
        }
      }

      return true;
    }

    /**
     * Mark the selected point as incomplete and update the score.
     * @method adventurejs.Scorecard#clearPoint
     * @param {String} point A string matching an point key.
     * @returns {Boolean}
     */
    clearPoint(key) {
      const point = this.getPoint(key);
      if (!point) return false;

      point.awarded = false;
      point.recorded = false;
      if ("undefined" !== typeof this.game.world._scorecard[key]) {
        this.game.world._scorecard[key] = false;
      }
      return true;
    }

    /**
     * Recursively count awarded points.
     * @method adventurejs.Scorecard#countAwardedPoints
     */
    countAwardedPoints(points = this.points, score = 0, total = 0) {
      for (const key in points) {
        const point = points[key];
        if (!point.bonus) {
          total += point.value;
        }
        if (point.awarded) {
          score += point.value;
        }
        if (point.points && Object.keys(point.points).length) {
          [score, total] = this.countAwardedPoints(point.points, score, total);
        }
      }
      return [score, total];
    }

    /**
     * Create a new point object with default values.
     * @method adventurejs.Scorecard#createPoint
     * @returns {Object}
     */
    createPoint() {
      let point = {
        value: 0,
        awarded: false,
        bonus: false,
        recorded: false,
        award_text: "",
        summary: "",
        action: null,
        parent: null,
        points: {},
        key: "",
        name: "",
        complete_text: "",
      };
      return point;
    }

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

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

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

      return null;
    }

    /**
     * Recursively find point by its text.
     * @method adventurejs.Scorecard#findPointByText
     * @param {String} key An object key to search for.
     * @param {Object} root The root object level to begin search.
     * @returns {Object|null}
     */
    findPointByText(text, points = this.points) {
      const comparator = this.game.settings.obfuscate_points
        ? A.obfuscate(text)
        : text;
      for (let key in points) {
        let point = points[key];
        if (point.name === comparator) {
          return point;
        }
        if (point.points) {
          let nested_points = this.findPointByText(text, point.points);
          if (nested_points) return nested_points;
        }
      }
      return null;
    }

    /**
     * Format the score/total before printing it to display.
     * @method adventurejs.Scorecard#formatScore
     */
    formatScore(score, total) {
      if (Object.keys(this.score_format).length) {
        let results = A.getSAF.call(this.game, this.score_format, this);
        if (results) return results;
      }
      return `${score}/${total}`;
    }

    /**
     * Get a point by numerical key or by its text or if a point object is received pass it on.
     * @method adventurejs.Scorecard#getPoint
     * @param {String|Object} point An id string or object reference.
     * @returns {Boolean}
     */
    getPoint(key) {
      let point;
      if ("object" === typeof key) {
        point = key;
        // } else if (!isNaN(key)) {
      } else if (key.startsWith("$")) {
        point = this.findPointByKey(key);
      } else if ("string" === typeof key) {
        point = this.findPointByText(key);
      }
      if (!point || "undefined" === typeof point.key) return false;
      return point;
    }

    /**
     * Get an itemized list of completed points.
     * @method adventurejs.Scorecard#itemizePoints
     */
    itemizePoints(points = this.points, count = 0, list = "") {
      let msg = "";
      let overview = "";

      // check points
      [count, list] = this.listPoints();

      if (count === 0) {
        overview = `{We've} earned nothing. Nothing! `;
        if (this.noscore) {
          overview = A.getSAF.call(this.game, this.noscore, this);
        }
      } else {
        list = `<ul class="ajs-score-item">${list}</ul>`;
        overview = `<span class="ajs-p">{We've} earned ${this.score} out of ${this.total} points.</span>${msg}`;
        if (this.overview) {
          overview = A.getSAF.call(this.game, this.overview, this);
        }
      }

      msg = overview + list;
      msg = msg.replace("{score}", this.score);
      msg = msg.replace("{total}", this.total);
      return msg;
    }

    /**
     * Get an itemized list of completed points.
     * @method adventurejs.Scorecard#listPoints
     */
    listPoints(points = this.points, count = 0, list = "") {
      // check points
      for (let key in points) {
        const item = points[key];
        if (Object.keys(item.points).length) {
          [count, list] = this.listPoints(item.points, count, list);
        }

        if (item.awarded) {
          count++;
          let itemize = item.text;
          // does score event have complete_text?
          if (item.complete_text) {
            itemize = A.getSAF.call(this.game, item.complete_text, this);
          }
          // wrap it
          list += `<li class="ajs-li">${itemize}</li>`;
        }
      }
      return [count, list];
    }

    /**
     * Process score data in longhand format.
     * @method adventurejs.Scorecard#processLonghand
     */
    processLonghand(points, group = this.points, parent = "") {
      if (!points) return;
      let groupcount = 0;
      let keys = Object.keys(points);
      for (let i = 0; i < keys.length; i++) {
        let name = keys[i];
        groupcount++;
        const value = points[name];
        // create a new empty point
        let key = parent ? `${parent}|${groupcount}` : `$${groupcount}`;
        group[key] = this.createPoint();
        const point = group[key];

        point.key = key;
        point.name = this.game.settings.obfuscate_points
          ? A.obfuscate(name)
          : name;
        point.text = this.game.settings.obfuscate_points
          ? A.obfuscate(name)
          : name;

        // we accept a number in the form of
        // "open window": 1,
        if ("number" === typeof value) {
          point.value = value;
          continue;
        }

        // or we accept an object with any subset of fields
        // "take andy": { value: 1 },
        for (var nested_key in value) {
          if (nested_key !== "points") {
            point[nested_key] = value[nested_key];
          }
        }

        // if it's already completed or recorded, ensure that's stored
        if (point.awarded) {
          this.awardPoint(key);
        }
        if (point.recorded) {
          this.recordPoint(key);
        }

        if (
          value.points &&
          "object" === typeof value.points &&
          Object.keys(value.points).length
        ) {
          this.processLonghand(value.points, group.points, key);
        }
      }
    }

    /**
     * Process score data in shorthand format.
     * @method adventurejs.Scorecard#processShorthand
     */
    processShorthand(points) {
      // break shortcut format into lines
      const lines = String(points)
        .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 (const line of lines) {
        count++;

        // separate # indicators from text
        const 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.points = node.points || {};

        const [name, raw_value] = line_body.split(/\s*[:|]\s*/);
        if (!name) continue;

        const value = Number(raw_value) || 0;
        if (isNaN(value)) {
          const msg = `The value of scorecard item "${name}" is not a number.`;
          this.game.log("L1593", "error", 0, msg, "Game");
        }

        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.points ? groupAtLevel : node;
          parent = attachTo;
          attachTo.points = attachTo.points || {};

          // attachTo.points[count] = this.createPoint();
          // target = attachTo.points[count];

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

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

          // node.points[count] = this.createPoint();
          // target = node.points[count];
          node.points[id] = this.createPoint();
          node.points[id].key = id;
          target = node.points[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.value = value;
        target.name = this.game.settings.obfuscate_points
          ? A.obfuscate(name)
          : name;
        target.text = this.game.settings.obfuscate_points
          ? A.obfuscate(name)
          : name;
        // target.key = count;
        // target.key = id;

        if (parent) target.parent = parent;
        // count++;
      }
    }

    /**
     * Mark the selected point as recorded after updating the score.
     * @method adventurejs.Scorecard#recordPoint
     * @param {String} point A string matching an point key.
     * @returns {Boolean}
     */
    recordPoint(key) {
      const point = this.getPoint(key);
      if (!point) return false;

      point.recorded = true;
      this.game.world._scorecard[key] = true;
      return true;
    }

    /**
     * This is a utility method that awards and also records a point.
     * Meant for debugging so author can award a point in the console.
     * @method adventurejs.Scorecard#completePoint
     * @param {String} point A string matching an point key.
     * @returns {Boolean}
     */
    completePoint(key) {
      let a = this.awardPoint(key);
      let r = this.recordPoint(key);
      return a && r;
    }

    /**
     * Restore the player's score from a saved file or undo.
     * @method adventurejs.Scorecard#restoreScore
     */
    restoreScore(points = this.points) {
      for (let key in points) {
        // cycle through scorecard
        if ("undefined" === typeof this.game.world._scorecard[key]) {
          // not found in saved game so make sure it's unset
          points[key].awarded = false;
          points[key].recorded = false;
        } else {
          points[key].awarded = this.game.world._scorecard[key];
          points[key].recorded = this.game.world._scorecard[key];
        }
        if (points.points && Object.keys(points.points).length) {
          this.restoreScore(points.points);
        }
      }
    }

    /**
     * Print score updates in a stack.
     * @method adventurejs.Scorecard#stackUpdates
     */
    stackUpdates(points = this.points) {
      let msg = "";
      for (let key in points) {
        const item = points[key];
        if (Object.keys(item.points).length) {
          msg += this.stackUpdates(item.points);
        }

        let eventmsg = "";
        let diff = item.value;

        if (item.awarded && !item.recorded && diff !== 0) {
          let direction = diff > 0 ? "up" : "down";
          let s = Math.abs(diff) > 1 ? "s" : "";
          this.diff = diff;

          this.recordPoint(key);

          if (item.award_text) {
            eventmsg = A.getSAF.call(this.game, item.award_text, this);
          } else {
            eventmsg = this.award_text
              ? A.getSAF.call(this.game, this.award_text, this)
              : `<!-- ${A.deobfuscate(item.text)} -->[ ** {Our} score went ${direction} by ${diff} point${s} ** ]`;
          }
          msg += `<span class="ajs-p ajs-score-msg ${direction}">${eventmsg}</span>`;
        }
      }
      return msg;
    }

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

        // handle full format
        else {
          this.processLonghand(scorecard.points);
        }
        delete scorecard.points;
      }

      // pass on any other properties - expected properties are
      // summarize_updates, award_text, score_format
      for (let prop in scorecard) {
        this[prop] = scorecard[prop];
      }

      return this;
    }

    /**
     * Update the player's score.
     * @method adventurejs.Scorecard#updateScore
     */
    updateScore() {
      const input = this.game.getInput();
      let msg = "";
      let [score, total] = this.countAwardedPoints();
      this.total = total;
      this.newscore = score;
      // @TODO if only 1 point then stack regardless of setting
      msg +=
        input.points.length > 1 && this.summarize_updates
          ? this.summarizeUpdates()
          : this.stackUpdates();
      this.score = score;
      this.game.display.updateScore(this.formatScore(score, total));
      if (msg) this.game.print(msg);
      return true;
    }
  }
  adventurejs.Scorecard = Scorecard;
})();