Example of creating a new command - ThePix/QuestJS GitHub Wiki
This page will describe the steps to go through when creating a new command. We are going to look at SHOOT [gun] AT [target], because this is a complex command so will give us the chance to explore a lot of the issues (also the example used in the Inform Recipe Book).
We need to think about all the ways the user might type the command. Let us suppose the player has a pistol, and a target, Henry.
SHOOT THE PISTOL AT HENRY
SHOOT HENRY
SHOOT PISTOL
FIRE PISTOL
SHOOT AT HENRY
SHOOT AT HENRY WITH GUN
USE GUN TO SHOOT HENRY
The first thing to note is that some of these are very different. Some use two items, some use just the gun, some use just the target. To ensure we can handle that, we are going to need to split that into different commands. It is therefore going to be more convenient to have each of those commands use a separate function that will handle the action in the world.
Infrastructure
We need a couple of items to test the system - the pistol and a guy called Henry. The pistol is set up as TAKEABLE and Henry as an NPC. The pistol is further flagged as a "gun", which we can test against later, and I have given it a synonym too. The NPC has a second version that we can swap in when the guy is shot. Sometimes it is easier to just set an attribute on an item (and the RPG library handles death in that way), but in this case a different item is easier so we do not need to worry about what happens if the player tells the corpse to get something or tries to have a conversation with it.
createItem("pistol", TAKEABLE(), {
loc:"lounge",
synonyms:['gun'],
gun:true,
ammo:6,
examine: "Just a regular pistol with {number:item:ammo} bullets in it.",
})
createItem("Henry", NPC(), {
loc:"lounge",
examine: "Just a regular guy.",
})
createItem("Henrys_corpse", {
alias:"Henry's corpse",
examine: "Just a regular guy, brutally murdered in the prime of his life.",
})
When transforming one thing to another it is usually a good idea to give the name of the original as a synonym for the replacement, as the user is liable to use the same name. Not required here as the new object's alias starts with the name of the original.
Shoot gun function - skeleton
All we want at this stage is the basic skeleton of the function so we know what the commands need to pass. So we can test, it will just print a simple message.
We could pass the function the weapon and the target:
function shootGunAt(weapon, target) {
msg("You shoot the " + weapon + " at the " + target)
return world.SUCCESS
}
It is generally better, however, to pass a dictionary, with attributes set to "item" (as per the usual QuestJS convention) and "target". We can also pass in the player object as "char", in case we later want to be able to tell an NPC to shoot someone.
function shootGunAt(options) {
msg("{nv:char:shoot:true} {nm:item:the} at {nm:target:the}.", options)
return world.SUCCESS
}
The advantage of using a dictionary is that we can add other options later if required. As a bonus, we can pass the dictionary to the "msg" function.
SHOOT GUN AT HENRY
We will do the two-noun command first.
new Cmd('ShootGunAtTarget', {
regexes:[
/^(?:shoot|fire) (.+) at (.+)$/,
{ regex:/shoot (.+) with (.+)/, mod:{reverse:true}},
/^use (.+?) (?:to shoot|shoot) (.+)$/,
],
objects:[
{scope:parser.isHeld, attName:'gun'},
{scope:parser.isNpcAndHere},
],
script:function(objects) {
return shootGunAt({char:player, item:objects[0][0], target:objects[1][0]})
},
})
There are three parts to it, the "regexes", the "objects" and the "script".
The "regexes"
The regexes are the most complicated part. There is an array to allow for three different formats, with the added complication that the second has the target before the gun, so needs to be in a dictionary so we can flag that.
/^(?:shoot|fire) (.+) at (.+)$/,
{ regex:/shoot (.+) with (.+)/, mod:{reverse:true}},
/^use (.+?) (?:to shoot|shoot) (.+)$/,
The ?:
is used to tell Quest we are not interested in saving this capture group.
In the last regex, the first capture group is (.+?)
. The question mark makes this "non-greedy", so the capture group will be as short as possible and will not try to steal "to" from the next bit if the user types USE GUN TO SHOOT HENRY.
The "objects"
The second part tells Quest what to expect from the capture groups. This will be the gun, and then the target. We can use this to hint what items Quest should try to match. Quest will try to match the first to an item that is held, and that has an attribute "gun". It will try to match the second to an NPC who is here. The more precise the information you give here, the better Quest will guess what the user means.
The "script"
The script does not do much, it just passes the items into a dictionary, which then gets passed to the main script. It returns whatever it gets from that script.
SHOOT GUN
This is the one-noun command. There are kind of two commands here, SHOOT GUN and SHOOT HENRY, but they use the same regex - both use the verb SHOOT - so are best done in one command. If they used different verbs I would split these into two different commands, which would be better.
The complications here are (1) deciding if the given item is the target or the weapon and (2) deciding what to use for the missing noun.
new Cmd('ShootGun', {
regex:/^(?:shoot|fire) (.+)$/,
objects:[
{scope:parser.isPresent},
],
script:function(objects) {
if (objects[0][0].gun) {
const npcs = scopeNpcHere()
log(npcs)
if (npcs.length === 0) return failedmsg("No one here to shoot!")
if (npcs.length > 1) return failedmsg("Who do you want to shoot?")
return shootGunAt({char:player, item:objects[0][0], target:npcs[0]})
}
else {
const guns = scopeBy(el => el.isHeld() && el.gun)
if (guns.length === 0) return failedmsg("You have no gun!")
if (guns.length > 1) return failedmsg("What do you want to shoot with?")
return shootGunAt({char:player, item:guns[0], target:objects[0][0]})
}
},
})
The "regexes"
There is only one regex, and it is very simple.
The "objects"
Again, just one. We cannot give the parser much clue here; it could be a gun the player is holding, or an NPC in the location.
The "script"
Now it is the script that is the complicated bit. The first thing it does is test the given object to see if it is flagged as a gun. If is is, it uses the first half of the script. It gets a list of potential targets. If it finds none, it reports an error. If it finds more than one, it similarly fails.
If the object is not a gun, it assumes the object is a target. It does more-or-less the same - looks for items that could work, then gives up if there are none or more than one.
Shoot gun function
Now we need to fill in that main script. Ultimately I want to be able to tell an NPC to shoot someone, so I am going to make this character-neutral. The "options" dictionary we send to the function has a "char" attribute that is the character doing the deed, and we can use text processor trickery to make the responses neutral.
Command scripts have two parts. The first part does checking, and will abort the process if something is not right - the player is not holding the gun, etc.
Note that we do still need to check the item and the target. The one-noun command will have got one of them right for sure, but we cannot be certain of the other. The "objects" list tells Quest what to look for, but it is a suggestion, not prescriptive. If it can, Quest will select the pistol, but not necessarily.
function shootGunAt(options) {
if (!options.item.gun) return failedmsg("Can't shoot anything with {nm:item:the}.", options)
if (!options.item.isHeldBy(options.char)) return failedmsg("{nv:char:don't:true} have {nm:item:the}.", options)
if (!options.item.ammo) return failedmsg("{nv:item:have:true} no ammo.", options)
if (!options.target.isHereOrHeldBy(options.char)) return failedmsg("{nv:target:be:true} not here.", options)
msg("{nv:char:shoot:true} {nm:item:the} at {nm:target:the}.", options)
options.item.ammo--
return world.SUCCESS
}
Note the order of checks in the function. If two conditions apply, which do you want reported as failing the command? That one needs to go higher. If the player does not have the pistol, and it has no ammo, I want Quest to report that command failed because the player is not holding the pistol, so that check needs to be before the ammo check.
Shooting Outcome
So we have hit a point where we need to decide what shooting someone - or something - in the game actually does. And this requires some thought. This is an issue whatever system you are using.
In theory the player should be able to shoot any NPC, or indeed any item, and that could easily break the game. If that vase needs to be used to carry water and the player shot it, the player is stuck. Or if the player shoots all the NPCs, she will have a hard time talking to them; even if they survive, they will not want to help. And what if she uses all the bullets before shooting the guy she is supposed to kill?
Just one target
The simplest approach is to simply prevent the player shooting at anything except the right target, so we will start there.
Death is handled by swapping out the item "Henry" with "Henrys_corpse". This is done using the "transform" function attribute on all items, which will handle pronouns and carried items for you. We also terminate his agenda.
Note that in the checks we need to check if Henry is dead before testing for NPCs, as the corpse is not an NPC.
function shootGunAt(options) {
if (!options.item.gun) return failedmsg("Can't shoot anything with {nm:item:the}.", options)
if (!options.item.isHeldBy(options.char)) return failedmsg("{nv:char:don't:true} have {nm:item:the}.", options)
if (!options.item.ammo) return failedmsg("{nv:item:have:true} no ammo.", options)
if (!options.target.isHereOrHeldBy(options.char)) return failedmsg("{nv:target:be:true} not here.", options)
if (options.target === w.Henrys_corpse) return failedmsg("He is already dead!", options)
if (!options.target.npc) return failedmsg("No need to go shooting up te place.", options)
if (options.target !== w.Henry) return failedmsg("{nv:target:be:true} not going to appreciate it if {nv:char:shoot} {ob:target}.", options)
msg("You shoot Henry, and he dies, a patch of blood spreading across the floor.", options)
options.item.ammo -= 1
w.Henry.transform(w.Henrys_corpse)
w.Henry.agenda = []
return world.SUCCESS
}
Multiple targets
An alternative approach, and the one we will assume for the rest of this discussion, is to give the target a "shoot" function attribute, and let that handle the consequences. If it returns true, the gun was shot, and ammo needs to be deducted. Personally I would use the above if there is only one item that can be shot; if there is more than one, do it this way.
We need to add "shoot" functions to the items.
createItem("pistol", TAKEABLE(), {
loc:"me",
synonyms:['gun'],
gun:true,
ammo:6,
examine: "Just a regular pistol with {number:item:ammo} bullets in it.",
shoot:function(options) {
return falsemsg("{nv:char:spend:true} ten minutes trying to get the gun to point at itself, but it seems to be made of metal and just too stiff to bend that way.", options)
},
})
createItem("Henry", NPC(), {
loc:"lounge",
examine: "Just a regular guy.",
shoot:function(options) {
msg("{nv:char:shoot:true} Henry, and he dies, a patch of blood spreading across the floor.", options)
w.Henry.transform(w.Henrys_corpse)
w.Henry.agenda = []
return true
},
})
createItem("Henrys_corpse", NPC(), {
alias:"Henry's corpse",
examine: "Just a regular guy, brutally murdered in the prime of his life.",
shoot:function(options) {
return falsemsg("He is already dead.")
},
})
And now our function looks like this. Note that it checks if the target has a "shoot" function, and if not just says no!
function shootGunAt(options) {
if (!options.item.gun) return failedmsg("Can't shoot anything with {nm:item:the}.", options)
if (!options.item.isHeldBy(options.char)) return failedmsg("{nv:char:don't:true} have {nm:item:the}.", options)
if (!options.item.ammo) return failedmsg("{nv:item:have:true} no ammo.", options)
if (!options.target.isHereOrHeldBy(options.char)) return failedmsg("{nv:target:be:true} not here.", options)
if (!options.target.shoot) return failedmsg(options.target.npc ? "{nv:target:be:true} not going to appreciate it if {nv:char:shoot} {ob:target}." : "No need to go shooting up the place.", options)
const flag = options.target.shoot(options)
if (flag) options.item.ammo--
return flag ? world.SUCCESS : world.FAILED
}
This uses the "conditional operator"; you may want to see here for details.
A refinement
We could have the system alert the user that it is guessing what item to use. Our ShootGun command needs to add something to the dictionary it passes to the main script.
new Cmd('ShootGun', {
regex:/^(?:shoot|fire) (.+)$/,
objects:[
{scope:parser.isPresent},
],
script:function(objects) {
log(objects)
if (objects[0][0].gun) {
const npcs = scopeNpcHere()
log(npcs)
if (npcs.length === 0) return failedmsg("No one here to shoot!")
if (npcs.length > 1) return failedmsg("Who do you want to shoot?")
return shootGunAt({char:player, item:objects[0][0], target:npcs[0], comment:'At ' + lang.getName(npcs[0])})
}
else {
const guns = scopeBy(el => el.isHeld() && el.gun)
if (guns.length === 0) return failedmsg("You have no gun!")
if (guns.length > 1) return failedmsg("What do you want to shoot with?")
return shootGunAt({char:player, item:guns[0], target:objects[0][0], comment:'With ' + lang.getName(guns[0])})
}
},
})
And that function needs to test if it is there, and print something if it is.
function shootGunAt(options) {
if (options.comment) metamsg('(' + options.comment + ')')
if (!options.item.gun) return failedmsg("Can't shoot anything with {nm:item:the}.", options)
if (!options.item.isHeldBy(options.char)) return failedmsg("{nv:char:don't:true} have {nm:item:the}.", options)
if (!options.item.ammo) return failedmsg("{nv:item:have:true} no ammo.", options)
if (!options.target.isHereOrHeldBy(options.char)) return failedmsg("{nv:target:be:true} not here.", options)
if (!options.target.shoot) return failedmsg(options.target.npc ? "{nv:target:be:true} not going to appreciate it if {nv:char:shoot} {ob:target}." : "No need to go shooting up te place.", options)
const flag = options.target.shoot(options)
if (flag) options.item.ammo--
return flag ? world.SUCCESS : world.FAILED
}
A further refinement
If the user does not specify a target, i.e., SHOOT GUN, it would be unwise to try to guess who to shoot if there are several NPCs in the room! However, if the player is holding four guns, does it matter which we use to SHOOT HENRY? This version of the one-noun command will assume the first loaded gun it finds, or the first empty gun otherwise.
new Cmd('ShootGun', {
regex:/^(?:shoot|fire) (.+)$/,
objects:[
{scope:parser.isPresent},
],
script:function(objects) {
log(objects)
if (objects[0][0].gun) {
const npcs = scopeNpcHere()
log(npcs)
if (npcs.length === 0) return failedmsg("No one here to shoot!")
if (npcs.length > 1) return failedmsg("Who do you want to shoot?")
return shootGunAt({char:player, item:objects[0][0], target:npcs[0], comment:'At ' + lang.getName(npcs[0])})
}
else {
const loadedGuns = scopeBy(el => el.isHeld() && el.gun && el.ammo)
const guns = scopeBy(el => el.isHeld() && el.gun)
if (guns.length === 0) return failedmsg("You have no gun!")
const gun = loadedGuns.length > 0 ? loadedGuns[0] : guns[0]
return shootGunAt({char:player, item:gun, target:objects[0][0], comment:'With ' + lang.getName(guns[0])})
}
},
})
Shooting the walls
If you are using BACKSCENE items and regions, you can allow the player to shoot the walls (or ceiling, etc.), just by giving the generic "wall" item a "shoot" function attribute.
The function stores the number of shots in an attribute of the current location, and gives the location an "addendum_examine_wall", which gets used by the BACKSCENE template to add to the description.
createItem("wall", BACKSCENE(), {
alias:"walls",
shoot:function(options) {
msg('{nv:char:shoot:true} the wall, leaving a hole. Someone is going to be annoyed...', options)
if (!currentLocation.wall_shoot_count) currentLocation.wall_shoot_count = 0
currentLocation.wall_shoot_count++
if (currentLocation.wall_shoot_count === 1) {
currentLocation.addendum_examine_wall = 'There is a gun shot hole in the wall.'
}
else {
currentLocation.addendum_examine_wall = 'There are {number:currentLocation:wall_shoot_count} gun shot holes in the wall.'
}
},
})
Telling an NPC to do the deed
We constructed the function so it would work for anyone - we just need to create new commands. For simple commands you can just flag the command with "npcCmd", and Quest will do the rest (eg see here). That will not work for our first command as it uses two items. And it will not work for the second because of issues when trying to guess the character to shoot.
We need to be able to handle both LARA, SHOOT HENRY and TELL LARA TO SHOOT HENRY, and I think that is easier to do in separate regexes (and for the former we also need to allow for no space after the comma).
Here is the two-noun command. We now have six regexes, and the second and fifth are complicated because we are not simply reversing the order, we are swapping the last two, so we flag these with "reverseNotFirst".
The script has a bit at the start to handle the NPC, and is common to all NPC commands.
new Cmd('NpcShootGunAtTarget', {
regexes:[
/^(.+), ?(?:shoot|fire) (.+) at (.+)$/,
{ regex:/(.+), ?shoot (.+) with (.+)/, mod:{reverseNotFirst:true}},
/^(.+), ?use (.+?) (?:to shoot|shoot) (.+)$/,
/^tell (.+) to (?:shoot|fire) (.+) at (.+)$/,
{ regex:/tell (.+) to shoot (.+) with (.+)/, mod:{reverseNotFirst:true}},
/^tell (.+) to use (.+?) (?:to shoot|shoot) (.+)$/,
],
objects:[
{scope:parser.isNpcAndHere},
{scope:parser.isHeld, attName:'gun'},
{scope:parser.isNpcAndHere},
],
script:function(objects) {
const npc = objects[0][0]
if (!npc.npc) {
failedmsg(lang.not_npc, {char:player, item:npc})
return world.FAILED
}
objects.shift()
return shootGunAt({char:npc, item:objects[0][0], target:objects[1][0]})
},
})
The one-noun command is a bit more complicated. Again double the regexes and again the same code at the start of the script.
If no NPC is given, we need to remove this NPC from the list. If no gun is given we need to look for a gun the NPC is holding.
new Cmd('NpcShootGun', {
regexes:[
/^(.+), ?(?:shoot|fire) (.+)$/,
/^tell (.+) to (?:shoot|fire) (.+)$/,
],
objects:[
{scope:parser.isNpcAndHere},
{scope:parser.isPresent},
],
script:function(objects) {
const npc = objects[0][0]
if (!npc.npc) {
failedmsg(lang.not_npc, {char:player, item:npc})
return world.FAILED
}
objects.shift()
if (objects[0][0].gun) {
const npcs = scopeNpcHere()
array.remove(npcs, npc)
log(npcs)
if (npcs.length === 0) return failedmsg("No one here to shoot!")
if (npcs.length > 1) return failedmsg("Who do you want {nm:char:the} to shoot?", {char:npc})
return shootGunAt({char:npc, item:objects[0][0], target:npcs[0], comment:'At ' + lang.getName(npcs[0])})
}
else {
const loadedGuns = scopeBy(el => el.isHeldBy(npc) && el.gun && el.ammo)
const guns = scopeBy(el => el.isHeldBy(npc) && el.gun)
if (guns.length === 0) return failedmsg("{nv:char:have:true} no gun!", {char:npc})
const gun = loadedGuns.length > 0 ? loadedGuns[0] : guns[0]
return shootGunAt({char:npc, item:gun, target:objects[0][0], comment:'With ' + lang.getName(guns[0])})
}
},
})
Reluctant NPCs
So far, the NPC told to shoot someone will just do it. We need to add this to our function, which will test if the NPC is willing to do it (if this is the player, agreement is assumed!).
if (!options.char.getAgreement("Shoot", {char:options.char, item:item, target:target})) return false
Then we can add a function to our NPCs that tests if he or she is up for it. We use a slightly different function on the NPC, and the reason for that is so we can have a default. The built in "getAgreement" function on the NPC will look first for a function to handle "Shoot", and if the NPC does not have one will fall back to a default, called "getAgreementDefault", and if that does not exist, just returns true
and the NPC does it.
So we will first do Henry, and give him a default. He will refuse any request.
createItem("Henry", NPC(), {
loc:"lounge",
examine: "Just a regular guy.",
shoot:function(options) {
msg("{nv:char:shoot:true} Henry, and he dies, a patch of blood spreading across the floor.", options)
w.Henry.transform(w.Henrys_corpse)
w.Henry.agenda = []
return true
},
getAgreementDefault:function() {
return falsemsg("'I'm not doing that,' exclaims Henry.")
},
})
For Lara, we can have the same default refusal because she is awkward and will not do most things we ask of her, but we will also have an exception for shooting stuff. In this version Lara will shoot anything we ask her to, but do nothing else.
createItem("Lara", NPC(true), {
loc:"lounge",
examine: "Just a regular psychotic woman.",
shoot:function(options) {
msg("Why would anyone shoot Lara?", options)
return false
},
getAgreementDefault:function() {
return falsemsg("'Do it yourself,' says Lara.")
},
getAgreementShoot:function() {
return true
},
})
We should make her a bit more discerning; for example, she may not want to shoot herself. The "getAgreementShoot" is passed a dictionary that we can use to test against. In this version she will shoot anyone except the player and herself.
getAgreementShoot:function(options) {
if (options.target === w.Lara) return falsemsg("'I'm not about to shoot myself!' says Lara.")
if (options.target === player) return falsemsg("'You really want me to shoot you?' asks Lara. 'Who's going to pay me then?'")
return true
},
An alternative approach is to default to refusing, and make Henry the exception.
getAgreementShoot:function(options) {
if (options.target === w.Henry) return true
return falsemsg("'I'm not happy about doing that,' says Lara.")
},
Which is better depends on the situation. If you want the NPC to default to agreeing do the first, otherwise the second.
Myself, yourself, himself, herself
I mentioned asking Lara to shoot herself, so far that will only work with LARA, SHOOT LARA, not LARA, SHOOT YOURSELF. Before we get to that, however, let us look at SHOOT MYSELF. We already have "me" and "myself" as synonyms for the player, so that is fine, we just need to give the player object a "shoot" function.
createItem("me", PLAYER(), {
loc:"lounge",
synonyms:['me', 'myself'],
examine: "Just a regular guy.",
shoot:function(options) {
msg("{nv:char:shoot:true} you{ifPlayer:char:rself} in the head. It hurts, but not for long.|Game over...", options)
io.finish()
},
})
The "msg" function uses some funky text processor trickery to determine if the character doing the shooting is the player or not, and hence whether we want "yourself" or "you". Then the game terminates.
For "yourself", "himself", "herself" we cannot do that, as these could be used for any NPC, and we cannot give them as synonyms to all of them. We will have to create more commands...
These are similar to before, but the regexes are looking for (?:your|him|her)self
, and we have one less object to match.
new Cmd('NpcShootGunAtSelf', {
regexes:[
/^(.+), ?(?:shoot|fire) (.+) at (?:your|him|her)self$/,
{ regex:/(.+), ?shoot (.+) with (?:your|him|her)self/, mod:{reverseNotFirst:true}},
/^(.+), ?use (.+?) (?:to shoot|shoot) (?:your|him|her)self$/,
/^tell (.+) to (?:shoot|fire) (.+) at (?:your|him|her)self$/,
{ regex:/tell (.+) to shoot (.+) with (?:your|him|her)self/, mod:{reverseNotFirst:true}},
/^tell (.+) to use (.+?) (?:to shoot|shoot) (?:your|him|her)self$/,
],
objects:[
{scope:parser.isNpcAndHere},
{scope:parser.isHeld, attName:'gun'},
],
script:function(objects) {
const npc = objects[0][0]
if (!npc.npc) {
failedmsg(lang.not_npc, {char:player, item:npc})
return world.FAILED
}
objects.shift()
return shootGunAt({char:npc, item:objects[0][0], target:npc})
},
})
new Cmd('NpcShootSelf', {
regexes:[
/^(.+), ?(?:shoot|fire) (?:your|him|her)self$/,
/^tell (.+) to (?:shoot|fire) (?:your|him|her)self$/,
],
objects:[
{scope:parser.isNpcAndHere},
],
script:function(objects) {
const npc = objects[0][0]
if (!npc.npc) {
failedmsg(lang.not_npc, {char:player, item:npc})
return world.FAILED
}
objects.shift()
const loadedGuns = scopeBy(el => el.isHeldBy(npc) && el.gun && el.ammo)
const guns = scopeBy(el => el.isHeldBy(npc) && el.gun)
if (guns.length === 0) return failedmsg("{nv:char:have:true} no gun!", {char:npc})
const gun = loadedGuns.length > 0 ? loadedGuns[0] : guns[0]
return shootGunAt({char:npc, item:gun, target:npc, comment:'With ' + lang.getName(guns[0])})
},
})