The Parser - ThePix/QuestJS GitHub Wiki

This documents the parser - and tells the users what is possible. As a game creator you would probably not need to touch the parser at all, but there may be times when you wonder what is going on.

The parser uses the same basic principle as Quest 5, which is comparing a list of commands with the input text, using regular expressions, but has some big differences.

Features

Supports ALL, ALL BUT, IT

You can type:

GET THE HAT

PUT IT AND TEAPOT IN THE OLD CHEST

DROP ALL BUT SWORD

The "it" will match to the last subject typed by the user, for a command the parser understood. If the hat turns out to be nailed to the ground, the parser will still understand the first command, so "it" will get set to the hat, even though the command failed.

After the second command, the teapot will be "it". The chest is the subject of he command, so not eligible.

It will remember separately for "it", "he", "she" and "they".

Note that this does mean the parser could be letting through objects that are no longer present, so game authors should check if items are present.

The ALL option excludes scenery items and NPCs.

Concatenated commands

Users can string commands together using full stops and "then".

get book, then read it.e.drop book

Shortcuts

You can use the arrow keys on the number pad (with number lock on) as a short hand for the compass direction, - and + for UP and DOWN (this feels right, as the - is above the +), / and * for IN and OUT. You can also 5 for LOOK or the dot for WAIT.

In "dev" mode, the 0 can be used for TEST, or with Alt and Ctrl for WT A and WT C (walk-throughs) respectively.

Context sensitive

The parser will attempt to guess what object the user referred to in various ways. These are mostly set up in the "objects" attribute of a command, which means they can be set for each item (for PUT HAT IN BOX, you can set for both the hat and the box).

Scope: It first looks for objects in the command's scope; for example, the REMOVE command has a scope of parser.isWorn, so the parser will look first at items that are worn, before falling back on any object visible if that fails. If a command has multiple objects, each can be given its own scope.

Item attribute: Secondly the parser will give priority to certain objects. It gives higher priority if the item has the right attribute. If you are in a room with a "garage key" and a "garage door", and type OPEN GARAGE, the parser will assume you mean the door, because that has an "open" attribute.

By default, the attribute will be the name of the command, but all lower case, but can be set with "attName" in the "objects" attribute.

Specific items: You can give an "items" attribute to entries in the "objects" array, and list the names of items to take priority

Item priority: Items can be given an innate bonus by giving them a "parserPriority" attribute (or set to a negative number if you want the parser to favour every other object).

This means that if there is a "box" and a "big box" and only the big box can be opened (and so has an "open" attribute, OPEN BOX will assume the big box. Or, if there is a room with a woman, Mary, and an item, map, the user can type M,GET M and the parse will understand that Mary should get the map.

See also here.

Disambiguation

This is when the parser cannot tell which item the user is referring to. Let us suppose there is a book, a pair of boots and seven bricks here, and the user types TAKE B. The parser cannot choose between the three, so offers a disambiguation menu.

If the player types BRICK, clearly the bricks will be taken.

If the player types BOO, the parser will select the first in the list that matches; the book in this case.

If the player types something that does not match any of the items, this will get treated as a new command, and the TAKE command will be abandoned.

Debugging

If you are wondering about the parser's decisions, type PARSER during play to turn the debugger on (in "dev" mode only). Now each time you type a command, it will tell you what it is considering as a possible match for both the command and the candidate objects, plus the scores.

Access the command

You can use parser.currentCommand to access information about the current command, either in your own commands or in the console. The "cmd" attribute (i.e., parser.currentCommand.cmd) is the command that was matched and "string" is what the user actually typed. The "all" attribute will be true if ALL was used, while the "objects" array will list matched items.

Under The Hood

So how does it actually work?

The basic principle is pretty straightforward. When the user types a command, the text is processed in a series of steps - preparation, selection, disambiguation and execution.

Preparation

Quest will pass the user's text to parser.parse, where it will be broken into individual commands, and each is passed to parser.parseSingle, which in turn passes the single command to parser.findCommand, which normalises the text by stripping out extraneous white space and converting to lower case.

Selection

All the commands are held in an array, and parser.findCommand goes through the array, calling the "matchItems" attribute on each one. This decides how good a match the command is, and if necessary checks the item matches too. The command giving the highest score is returned to parser.parseSingle.

Disambiguation

Assuming a good match, the parser.parseSingle function then handles disambiguation.

Execution

Once that is done, it calls parser.execute. That in turn saves any items for use as pronouns next time, then calls the "script" function of the command.

The parser itself, then, is not that complicated, the trick part happens in the "matchItems" function of the command.

The "matchItems" function in commands

This function is automatically added to all your commands, but it is just possible you will want to write your own, so we will go though what it does.

The function is passed the normalised string as a parameter, but is also passed the text the user types as a second parameter in case you ever want to use that.

It creates a dictionary attribute for the command called "tmp" that will exist only for the duration of the command. This will hold the results of the matching process.

  • objectTexts - the matched object names from the player input
  • objects - the matched objects (lists of lists ready to be disambiguated)
  • score - a rating of how good the match is
  • error - a string to report why it failed, if it did!

The objects will be an array for each object role (so PUT HAT IN BOX is two), of arrays for each object listed (so GET HAT, TEAPOT AND GUN is three), of possible object matches (so GET HAT is four if there are four hats in the room). After disambiguation, the lowest level will be resolved into a single object, but the "matchItems" function can rely on that happening elsewhere.

The score is a rating for how well this command matches, based on the score attribute of the command itself (defaults to 10); if zero or less, this is an error.

If this does give an error, it is only reported if no command is a success

In the first place, this uses the _test function to decide if the regular expressions match. If not, it sets the score to -100 and gives up.

Other Parser Functions

Accessing directly

You can send a string to the parser using runCmd. For example:

runCmd('north')

This will have exactly the same effect as the player typing NORTH, including echoing the command (unless you turned that off in you game). If you do not want the command echoed, do this instead:

parser.parse('north')

This is also used by both text input and commands from clicking the side pane.

parser.overrideWith(function)

You can use this to temporarily subvert the parser. The command string sent to parser.parse will get passed to the given function, and no other action will be taken. This is used when asking the user a question, as you want to capture the response to handle it, rather than have the parser deal with it.

Once it has been used the function is automatically discarded and normal service resumes. You can force that to happen by calling parser.overrideWith() (i.e., with no parameters). This may be useful if the player does a mouse click instead of text input, and you need to clear it ready for the next command.

It is often a good idea to change the cursor so the player knows the usual commands are not applicable. This can be done by just adding a string after the function. This example adds text to make clear what is expected (and uses the text processor to make it blue).

    msg("The computer asks for a password.")
    parser.overrideWith(function(s) {
      msg("You entered: " + s)
    }, "{colour:blue:[Enter password] >}")

You can add a further parameter for the text that is echoed back to make clear this is not a command, but without the text. Note you cannot use the text processor here.

    msg("The computer asks for a password.")
    parser.overrideWith(function(s) {
      msg("You entered: " + s)
    }, "{colour:blue:[Enter password] #}", "#")

parser.currentCommand

The current command can be accessed through this. As discussed above, this will have a dictionary with the data for the matching process:

Name Type Comment
string string The input text, as typed
cmdString string The input text as the parser saw it
objects array The matched items; this is what gets passed to the command's "script" attribute
objectTexts array The text the items were matched against
score number The score evaluated by the parser when deciding hood good the match was

parser.pronouns

What object does "it" "her" etc. currently point to?

If you have an object transform in to another, you may want to set this so "it" continues to point to the new object.

parser.keepTogether

The parser uses this to decide if a line of text input should be treated as a single command or should be broken up by full stops. The only use-case I can think of is for comments - if the text starts with an asterisk, the parser should treat it as one command, regardless of any full stops in it.

This then is the default:

parser.keepTogether = function(s) {
  return lang.regex.MetaUserComment.test(s)
}

It is just possible an author might want different types of comments, so could start each type with a different character.

parser.matchToName

Finds a suitable item for the given string fragment, scopes (array of item arrays) and command parameters (the objects attribute for this bit of the command). It is also passed objs; this is an array that the found object lists are added to. We do not want to add duplicates, so this is accumulative.

The complicated part is removing duplicates, which can happen if a name has a comma or "and" in it.

parser.findInScope

Finds a suitable item for the given string fragment and array of item arrays, with a set of options, cmdParams, too.

First it handles IT, etc. Then it looks for suitable objects in the array of item arrays. This is the various scopes; if it finds a suitable object in the first scope, it will go with that, but if it fails, it will try the next.

It returns a list of found objects, plus a score that is simply which scope the items were found in; if the first of three scopes, the score is three. If the last, it will be one. Note that the score will be one if a pronoun was used - pronouns ignore scope.

NOTE: I can see an issue here with the user doing GET IT when the item IT refers to is no longer here. Authors might expect the scoping to ensure the item is here, and unless they test really carefully, that might appear to be reliable. It would be possible to check IT is in scope at this point, but I think that has more potential to be confusing, so have chosen not to.

parser.findInList

Finds a suitable item for the given string fragment and item array, with a set of options, cmdParams, too.

For each item in the list, it gets a score using parser.scoreObjectMatch. It returns an array of the highest scoring items, which could be empty if no suitable iems were found.

parser.itemSetup

The parser needs to derive a few values for every item, and it is best to just do this once, when the item is first encountered (called by parser.scoreObjectMatch). It sets "parserOptionsSet" to flag that it has been done.

attribute|type|comment parserItemName|string|the alias, in all lower case parserItemNameParts|string array|each word in the alias and alternative names, plus combinations regex|regex|if this does not already exist, it will be set from "pattern" parserAltNames|string array|if this does not already exist, it will be set from "pattern"

You could override this. Suppose you have items with double quotes in the names, it would be best to have the parser ignore them. This version, which only differs in the third line, will strip all none alphanumeric characters from the alias the parser uses.

  parser.itemSetup = function(item) {
    item.parserOptionsSet = true
    item.parserItemName = item.alias.toLowerCase().replace(/[^\w ]/g, '')
    item.parserItemNameParts = array.combos(item.parserItemName.split(' '))
    if (item.pattern) {
      if (!item.regex) item.regex = new RegExp("^(" + item.pattern + ")$") 
      if (!item.parserAltNames) item.parserAltNames = item.pattern.split('|')
    }
    if (item.parserAltNames) {
      item.parserAltNames.forEach(function (el) {
        if (el.includes(' ')) {
          item.parserItemNameParts = item.parserItemNameParts.concat(el.split(' '))
        }
      })
    }
  }

parser.scoreObjectMatch

Determines a score for the given string fragment and item, with a set of options, cmdParams, too.

The score is:

score when
100 cmdParams.items includes the name of the item
60 string exactly matches the alias
55 string exactly matches the regex
50 string exactly matches a part of the name
15+n string matches the first n letters of the alias
10+n string matches the first n letters of a part of an alternative alias
n string matches the first n letters of a part of the name
-1 No match (returned directly)

In addition, an item will have a +20 bonus if the item has an attribute cmdParams.attName. If the item has a "parsePriority" attribute, this is also added.

The item's "cmdMatch" attribute is set to the string fragment in case we need to later check what it was matched against and the score returned.

Note that item scores are used only when comparing items for a specific command; they are not considered when comparing commands.