Pre-release
AdventureJS Docs Downloads
Score: 0 Moves: 0
// joinCompoundPhrases.js

(function () {
  /* global adventurejs A */

  var p = adventurejs.Parser.prototype;

  /**
   * <p>
   * Search input for multi-word phrases that are not names,
   * such as "north door" to describe an aperture related
   * to a north exit,
   * by comparing the input string against entries in
   * game.world_lookup, which contains space delimited phrases
   * as well as single words which make up those phrases.
   * </p>
   * <p>
   * For example if we had an object named:
   * "Maxwell's silver hammer"
   * </p>
   * <p>
   * User might ask for "hammer" or "silver hammer" or "maxwell's silver hammer"
   * In order to maximize chances of understanding partial user input,
   * each word of the phrase becomes a separate entry in world_lookup.
   * </p>
   * <p>
   * When performing search/replace we don't want to
   * accidentally replace substrings of larger phrases,
   * which means we need to search for longer phrases first
   * and work our way down. We also need to ensure we don't
   * substitute words in phrases we've already serialized.
   * </p>
   * <p>
   * Unfortunately we can't just sort world_lookup by word count
   * because JS object properties aren't indexed, by definition.
   * Instead we figure out the longest word count property and
   * do a loop for each word count from the longest down to two words.
   * (No substitution is needed for single words.)
   * </p>
   * @memberOf adventurejs.Parser
   * @method adventurejs.Parser#joinCompoundPhrases
   * @param {String} input Player input.
   * @returns {String}
   * @todo
   * This might be too greedy, or perhaps need to exclude first word of input.
   * I had a room called "Put Plug In This"
   * and this method parsed "put plug in sink" to "put_plug_in_this in sink"
   * Alternately, revise the method that populates world_lookup
   * for instance world_lookup has keys for
   * "Plug Something With Something" and "sink with"
   */
  p.joinCompoundPhrases = function Parser_joinCompoundPhrases(input) {
    this.game.log(
      "L1467",
      "log",
      "high",
      `[joinCompoundPhrases.js] joinCompoundPhrases() receive: ${input}`,
      "Parser"
    );

    const reconstitute = function (input, regex, sub) {
      let source = "";
      input = input.replace(regex, (match, leading, keyMatch, trailing) => {
        // capture $2 (the original input)
        source = keyMatch;

        // return your desired replacement
        return `${leading}${sub}${trailing}`;
      });
      return [input, source];
    };

    var world_lookup = Object.keys(this.game.world_lookup);
    var longest_key = this.game.longest_key;
    var this_turn = this.game.getInput();

    // iterate from longest to shortest, excluding single words
    for (var longkey = longest_key; longkey > 0; longkey--) {
      for (var i = 1; i < world_lookup.length; i++) {
        let worldkey = world_lookup[i];
        // don't find single words
        if (
          worldkey.split(" ").length === longkey &&
          worldkey.split(" ").length > 1 &&
          worldkey.split("_").length === 1
        ) {
          var ids = this.game.world_lookup[worldkey].IDs;
          /*
           * Exclude keywords with underscores because
           * those will be the results from earlier loops.
           * Ex: we're going to search for "icy door"
           * and convert to "icy_door"
           * and we're also going to search for "icy"
           * and we don't want to wind up with "icy_doordoor".
           *
           * We search with word boundaries.
           * When we serialize we're replacing spaces with underscores
           * and underscore is not a word boundary,
           * so for example search will find "icy " or " icy" or " icy "
           * but it won't find "icy" in "icy_door".
           */

          // OLD VERSION
          // var search = "\\b" + worldkey + "\\b";
          // var regex = new RegExp(search, "g");

          // NEW VERSION BECAUSE PERIODS (ie Dr.)
          // accommodating for periods in names means we can't
          // just use \\b word boundaries, have to escape periods
          // important to note this limits valid chars
          const wordChars = "A-Za-z0-9.–";
          const escapedKey = worldkey.replace(/[.*+?^${}()|[\]\\]/gi, "\\$&");
          const regex = new RegExp(
            `(^|[^${wordChars}])(${escapedKey})([^${wordChars}]|$)`,
            "ig"
          );

          if (regex.test(input.toLowerCase())) {
            let source = "";

            // One clear match found.
            // This lookup key had a singular object associated with it.
            // Ex: "wooden chest" returns "wooden_chest".
            if (ids.length === 1) {
              [input, source] = reconstitute(input, regex, ids[0]);

              // save a replacement record back to input
              // so we can find the original input string,
              // which may not be identical to the name
              // of the object we find
              let context = `parser.joinCompoundPhrases() found singular lookup value and replaced '${ids[0]}' with '${worldkey}'`;
              if (!this_turn.tokens[ids[0]]) {
                this_turn.tokens[ids[0]] = {
                  key: ids[0],
                  source: source,
                  context: context,
                };
              }

              // @TODO we should break here if the match is exact
              // because there should be no need to continue
              // break;

              // otherwise continue
              continue;
            } else {
              // we're going to use foundOne for testing after we run the for-loop
              var foundOne = false;
              this.game.log(
                "L1107",
                "log",
                "high",
                `[joinCompoundPhrases.js] joinCompoundPhrases() world_lookup[${worldkey}] found ids: ${ids}`,
                "Parser"
              );
              for (var k = 0; k < ids.length; k++) {
                this.game.log(
                  "L1108",
                  "log",
                  "high",
                  "[joinCompoundPhrases.js] joinCompoundPhrases() ids: " +
                    ids[k],
                  "Parser"
                );

                /*
                 * The lookupKey found multiple values,
                 * which means an array of serialized IDs.
                 *
                 * Ex: we searched for "brass key" and found "small_brass_key"
                 * but also "melted_brass_key" and "broken_brass_key"
                 *
                 * So we need to deserialize each value to see if it's found in input.
                 * And as with the parent routine, we need items
                 * ordered from longest word count to shortest.
                 *
                 * Fortunately we already sorted these at startup
                 * in Game.play.sortLookupValues().
                 */
                var lookup_value_deserialized = A.deserialize(ids[k]);

                /*
                 * IMPORTANT: don't replace single words.
                 * This was already the intention, but this
                 * line fixes a bug when processing input such as:
                 * "take all keys but fookey".
                 * Prior to this step, "but fookey" has been converted
                 * to "-fookey", giving us "take all keys -fookey".
                 * This loop was finding "fookey" and replacing
                 * "all keys" with "fookey", leading to malformed
                 * input: "take fookey -fookey"
                 */
                if (-1 === lookup_value_deserialized.indexOf(" ")) {
                  continue;
                }

                // we found an exact match
                // Ex: user input included the phrase "melted brass key"
                // so we can exclude "brass_key" and broken_brass_key"
                if (-1 !== input.indexOf(lookup_value_deserialized)) {
                  [input, source] = reconstitute(input, regex, ids[k]);

                  foundOne = true;

                  // save a replacement record back to input
                  if (!this_turn.tokens[ids[k]]) {
                    this_turn.tokens[ids[k]] = {
                      key: ids[k],
                      source: worldkey,
                      context: `parser.joinCompoundPhrases() found unambiguous match`,
                    };
                  }
                }
              }
              if (false === foundOne) {
                this.game.log(
                  "L1109",
                  "log",
                  "high",
                  "[joinCompoundPhrases.js] joinCompoundPhrases() Need to disamiguate " +
                    ids,
                  "Parser"
                );
                /*
                 * We need to disambiguate, but we're going to kick
                 * that can down the road. Normally we would convert
                 * multiples to an array – but unfortunately
                 * we're still working with strings at this point –
                 * because this may be just one "word" of player input.
                 * Instead we're going to pass on all found object IDs
                 * in a comma delimited list.
                 *
                 * Important: we're saving the original input as the first
                 * item in the comma-delimited list. Down the road,
                 * parseNoun will use that to set parsedNoun.input
                 *
                 * @TODO change this to some kind of token scheme
                 */
                let compound = A.serialize(worldkey) + "=" + ids.toString();

                [input, source] = reconstitute(input, regex, compound);

                // save a replacement record back to input
                // this version saves, ie,
                // melted_brass_key,tiny_brass_key,giant_brass_key
                if (!this_turn.tokens[ids]) {
                  this_turn.tokens[ids] = {
                    key: ids,
                    source: worldkey,
                    context: `parser.joinCompoundPhrases() looking for disambiguation`,
                  };
                }
                // @TODO this version is new
                // it should save, ie,
                // brass_key=melted_brass_key,tiny_brass_key,giant_brass_key
                // it's debatable whether the prior one is needed
                if (!this_turn.tokens[compound]) {
                  this_turn.tokens[compound] = {
                    key: compound,
                    source: worldkey,
                    context: `parser.joinCompoundPhrases() looking for disambiguation`,
                  };
                }
              }
            }
          } // if( match !== null )
        }
      } // for( var i = 1; i < world_lookup.length; i++ )
    } // for( var longkey = longest_key; longkey > 0; longkey-- )

    var searchall = "\\ball_";
    var regexall = new RegExp(searchall, "g");
    input = input.replace(regexall, "");

    this.game.log(
      "L1110",
      "log",
      "high",
      `[joinCompoundPhrases.js] joinCompoundPhrases() return: ${input}`,
      "Parser"
    );
    return input;
  };
})();