The respond function - ThePix/QuestJS GitHub Wiki

Often in a text adventure one command (or other action) can have numerous outcomes, depending on the situation at the time. The respond function is a general way to handle this. The basic principle is that it has a list of possible outcomes, and it searches through for one that fits the current situation, and uses that.

This is much like a decision tree, especially if you nest the responses, which is a very common algorithm in computer games.

It takes two parameters. The first, "params", is a dictionary which can be given any name-values relevant, though "char" should be amongst them; this describes the situation. The second parameter is an array of possible responses.

The system for handling ASK/ABOUT for NPCs uses this, so we can use that to illustrate (the reactions system, as well as SAY command and item giving system do too; if you use dynamic hints, that uses it too.).

In this code a set of key-value pairs is assembled in a dictionary. This will be passed around various things later, so needs all the relevant details we might want to access later. Let us suppose the user types ASK KYLE ABOUT THE HOUSE. The "char" is Kyle, "text" is set to THE HOUSE, "text2" to ABOUT and "action" to ASK.

The "list" parameter is all the possible responses, and we will look at that in a moment.

    const params = {
      text:'the house',
      text2:'about',
      char:w.Kyle,
      action:'ASK',
    }
    return respond(params, list)

If no response is found, the function will report an error - the system does expect you to have a default that will catch everything.

If a response is found, and that response is flagged as "failed" (i.e., the "failed" attribute is set to true) if will return false, otherwise it will return true. For a command, this would correspond to the command succeeding or failing; in this example, if there is no topic matching "the house", we would consider that a fail.

The responses

The responses in the array might look like this.

  [
    {
      test:function(p) { return p.text.match(/(house|home)/); }, 
      msg:"'I like it,' says Kyle.",
    },
    {
      test:function(p) { return p.text.match(/garden/) && w.garden.fixed; },
      msg:"'Looks much better now,' Kyle says with a grin.",
    },
    {
      test:function(p) { return p.text.match(/garden/) && w.Kyle.needsWorkCount === 0; },
      msg:"'Needs some work,' Kyle says with a sign.",
      script:function(p) { w.Kyle.needsWorkCount++; },
    },
    {
      regex:/garden/,
      msg:"'I'm giving up hope of it ever getting sorted,' Kyle says.",
    },
    {
      msg:"Kyle has no interest in that.",
      failed:true,
    }
  ],

Each entry in the array is a dictionary; these are the attributes that are used.

name type comments
name string Not used, but can be useful to identify an entry when things go wrong!
id string An ID will only be used once for a certain NPC. If you want a response to be used every time, do not give it an ID. If you want it only to happen once, give it an ID. If you want just one use from a whole set of responses, give them all the same ID.
test function(params) A function that is sent the "params" dictionary and this response, and should return true if this is a suitable response.
regex regular expression A regular expression that must match params.text if this response is to be used (this is kind of a short-cut for test as matching against the "text" attribute is so common).
msg string A message to print if this response is used. The charscter's "msg" function is called (which means the text is only seen if the player is present). The "params" will be used to provide parameters for the text processor.
script function(params) A function to run if this response is used; it is sent the "params" dictionary and this response.
failed Boolean If true this indicates this response should be considered a failed command, and the function will return false.
responses array of dictionaries See later

All but the last entry in the list should have a "test" or a "regex" attribute (or both). All entries need one of "msg", "script" or "failed", but can have two or even three of them.

Nested responses

Responses can be nested. In the above example, three responses are matching "garden". We could have a single test for that, and give it its own "responses" array. Each test in that group need only check the specific condition that applies for it; no need to check the text match again. The last entry in the "responses" needs no "test" of its own. These responses could have their own "responses" array, and these can go as deep as you like.

  askoptions:[
    {
      test:function(p) { return p.text.match(/house/); }, 
      msg:"'I like it,' says Kyle.",
    },
    {
      test:function(p) { return p.text.match(/garden/) },
      responses:[
        {
          test:function(p) { return w.garden.fixed; },
          msg:"'Looks much better now,' Kyle says with a grin.",
        },
        {
          test:function(p) { return w.Kyle.needsWorkCount === 0; },
          msg:"'Needs some work,' Kyle says with a sign.",
          script:function(p) { w.Kyle.needsWorkCount++; },
        },
        {
          msg:"'I'm giving up hope of it ever getting sorted,' Kyle says.",
        },
      ],
    },
    {
      msg:"Kyle has no interest in that subject.",
      failed:true,
    },
  ],

The advantage of nesting is that it can be easier for the creator to see what situations are properly covered where. You know all the responses about the garden are in that one place, and you can see there is one that has no test and so act as a default. It will be quicker too, as Quest can reject large groups of responses in one go, but the difference will be so slight no one will notice. It does add a level of complexity, and whether that is helpful to you is for you to decide; there is no difference to the game-play.

Verifying

Every "responses" array, including the top level one, should end with an entry with no test, the default catch-all. You can send the list to util.verifyResponses to confirm that that is the case. This works recursively, so to give you a clue where it found an error reports the "depth". A depth of 1 is the top level, a depth of 2 indicates one level of nesting, and so on.

Say we have a character called "Kyle", with a list of responses in "askOptions". In the console, type:

util.verifyResponses(w.Kyle.askOptions)

It will tell you of any problems, and will then say "Done".

Further Options

Your "params" dictionary can contain any data that is going to be useful to your tests and scripts, but at a minimum should include "char", the character this relates to. In addition you can include:

beforeScript(params, response): This function will be run before the script of the selected response has run and its message printed. Note that it will not be used if no response was found

afterScript(params, response): This function will be run after the script of the selected response has run and its message printed. Note that response will be undefined if no response was found - and it will run either way.

extraTest(params, response): This function will be run when testing every response; if it returns false the response will be rejected, just as if its own "test" function returned false. I.e., a response will only be selected if both this and its own "test" returns true (if "extraTest" exists). The main use for this is to check a custom attribute on the responses in your list.

noResponseNotError: If set to true no error will be thrown if there is no response found. Generally this is bad as you want the user to see something, but the reactions system uses this because it is fine for the user to see nothing when an NPC is not reacting.

Custom attributes for responses

An important use of "extraTest" is to allow you to add custom attributes to test against. For an example of these in action, look at how SAY is handled. The possible responses have two extra attributes, a "regex" to test the text again and, in some cases, an "id" to store to ensure the response is only used once (these are now built-in, but the principle is the same). Here is an example:

  sayResponses:[
    {
      regex:/^(hi|hello)$/,
      id:"hello",
      script:function() {
        msg("'Oh, hello there,' replies Lara.")
        if (w.Kyle.isHere()) {
          msg("'Have you two met before?' asks Kyle.")
          w.Kyle.askQuestion("kyle_question")
        }
      },
    }
  ],

The "extraTest" function checks these, while "afterScript" handles saving the "id" and other house-keeping. Here is the code in _npc.js that handles that.

  res.sayResponse = function(s) {
    if (!this.sayResponses) return false

    const params = {
      text:s,
      char:this,
      extraTest:function(params, response) {
        if (!response.regex) return true
        if (response.id && params.char.sayUsed.match(new RegExp('\\b' + response.id + '\\b'))) return false
        return response.regex.test(params.text)
      },
      afterScript:function(params, response) {
        if (!response) return
        params.char.sayBonus = 0
        params.char.sayQuestion = false
        if (response.id) params.char.sayUsed += response.id + " "
      },
      noResponseNotError:true,
    }
    return respond(params, this.sayResponses)

Example: Handling steps for NPCs

The schedule can be used to determine what phase we are in the game - is it dinner time, has the party started? But sometimes you need to orchestrate events on a turn-by-turn basis, and to have those events change depending on what the player is up to, and the respond function offers a way to do that.

By way of an example, we will combine the two approaches. We will set up a schedule, but each entry will have several steps that need to interact with the player.

First the schedule. We start by waiting one turn to let the player get her bearings, then we do the starter, the main course and the desert.

createItem("dinner_timetable", AGENDA_FOLLOWER(), {
  suspended:true,
  agenda:[
    'wait',
    'run:stepped:starter',
    'run:stepped:main',
    'run:stepped:desert',
  ],
  stepped:function(arr) { return !respond({course:arr[0], actor:w.Kyle}, this.steps) },
})

Each course is going to be handled by the function, w.dinner_timetable.stepped, which is going to use the respond function. If the respond function returns true, we will continue with the same course, so need to return false for the agenda. There is, therefore, the exclamation mark to reverse the Boolean result. The respond function is sent a dictionary that contains the name of the course and our actor. It is also given the responses, an array called "steps", that we still need to add.

All the clever stuff happens in that array. I am going to nest it, so each course has its own sub-list. Here is a start - it only has two courses, and each has only one step so far. At the top level, it just checks what course we are on. Each entry just gives a message, and flags "failed" as true, which will cause this phase of the agenda to terminate, and move us on to the next course. At this point, it should be playable.

createItem("dinner_timetable", AGENDA_FOLLOWER(), {
  agenda:[
    'wait',
    'run:stepped:starter',
    'run:stepped:main',
    'run:stepped:desert',
  ],
  stepped:function(arr) { return !respond({course:arr[0], actor:w.Kyle}, this.steps) },
  steps:[
    {
      test:function(p) { return p.course === 'starter' },
      responses:[
        {
          msg:"Kyle eats the soup.",
          failed:true,
        },
      ],
    },
    {
      test:function(p) { return p.course === 'main' },
      responses:[
        {
          msg:"Kyle produces the main course.",
          failed:true,
        },
      ],
    },
    {
      failed:true,
    }      
  ],
})

Let us suppose Kyle, the master chef, is making soup for the starter.

  • His first step is opening a can of soup, so this is top priority. However, he only needs to do this if the can is not already open.
  • His second step is pouring soup into bowls. However, he only needs to do this if they do not already have soup in.
  • His third step is microwaving the bowls. However, he only needs to do this if they do not already have HOT soup in.
  • His fourth step is to put the bowls on the table - but only if not already there.

The system starts. The first step test says it is outstanding, so Kyle opens the soup. Let us suppose the player pours the soup in the bowls. Now when we come to the steps we find that the first two steps are to be skipped, so we go straight to the third.

Now suppose the player empties the bowls down the sink. We go through the list again, first step is done, but second step is outstanding, so now that is done.

Here is how it could be implemented. Note that at each step we change an attribute that can later be used to test the current game state. It is important that these also get changed by other events going on. If the player empties the bowls, it is vital w.bowls.state gets set to zero.

createItem("dinner_timetable", AGENDA_FOLLOWER(), {
  agenda:[
    'wait',
    'run:stepped:starter',
    'run:stepped:main',
    'run:stepped:desert',
  ],
  stepped:function(arr) { return !respond({course:arr[0], actor:w.Kyle}, this.steps) },
  steps:[
    {
      test:function(p) { return p.course === 'starter' },
      responses:[
        {
          test:function() { return !w.soup_can.opened },
          script:function() {
            w.soup_can.opened = true
            msg("Kyle opens the soup can.")
          },
        },
        {
          test:function() { return w.bowls.state === 0 },
          script:function() {
            w.bowls.state = 1
            msg("Kyle pours soup into the two bowls.")
          },
        },
        {
          test:function() { return w.bowls.state === 1 },
          script:function() {
            w.bowls.state = 2
            msg("Kyle microwaves the two bowls.")
          },
        },
        {
          test:function() { return w.bowls.state === 2 },
          script:function() {
            w.bowls.state = 3
            msg("Kyle serves the two bowls of delicious soup.")
          },
        },
        {
          msg:"Kyle eats the soup.",
          failed:true,
        },
      ],
    },
    {
      test:function(p) { return p.course === 'main' },
      responses:[
        {
          msg:"Kyle produces the main course.",
          failed:true,
        },
      ],
    },
    {
      failed:true,
    }      
  ],
})

This is a simple system. In principle, your system could go down different branches depending on what the player does. Perhaps Kyle just gives up if the player tips away the soup three times...