Handling TIActions with Multi-Methods

by Eric Eve

Introduction

The adv3Lite library, like the adv3 library, takes an object-oriented approach to handling actions. Most of the code for handling an action is implemented in methods of the object or objects involved. For example, when the player issues the command TAKE KNIFE, taking the knife will be handled by methods defined on the knife object, or, more likely, the Thing class. In typical adv3Lite coding this will be found in the dobjFor(Take) block, a shorthand way of defining preCondDobjTake, verifyDobjTake(), checkDobjTake(), actionDobjTake(), and reportDobj(Take), which between them ensure that any preconditions for the action are met (the player character can touch the knife), that the action makes sense (the knife isn't fixed in place or already held by the player character), and that the player has room to hold the knife, and then, if all goes well, moving the knife into the player's inventory and reporting that the action has taken place. When just one object is involved in the action (because it's a TAction that takes only a direct object), this all works well.

Things become a little more complicated with a TIAction, which involves two objects, a direct object and an indirect object. Consider PUT KNIFE IN BOX, for example. This effects both the knife (which may be moved) and the box (which will have something moved into it). This raises the question which object should host the code that handles the action. In the library, it's usually split between the two. The direct object enforces the PreConditions that the direct object is being held but not worn by the actor, and its verify stage checks that we're not trying to put the direct object in itself (PUT BOX IN BOX wouldn't make sense), that the direct object is not already in the indirect object (the knife isn't already in the box), and that the direct object isn't fixed in place. It's also responsible for reporting that the action has taken place (if indeed it does). The indirect object enforces the PreConditions that the indirect object is reachable (the player character can touch the box) and that its interior is accessible (for example, that it's not closed). At the verify stage it checks whether the indirect object is the kind of thing we can put things in: PUT BOX IN BOX makes sense but PUT KNIFE IN TABLE probably doesn't (more on which below). At the check stage it checks whether the indirect object has enough space for the direct object - maybe the box is too small for the knife or maybe it's full of other stuff already. Finally it's the indirect object (here, the box) that carries out moving the direct object into itself at the action stage if the action has passed the preCond, verify and check stages on both objects.

Clearly this works fine in practice, and for the most part there's reasonably clear logic to which of the two objects we assign particular tasks to, for example the direct object is responsible for ensuring it can be moved and the indirect object for ensuring it's open and has space available. Some areas may seem more arbitrary, moving the knife into the box could have been carried out by the direct object's action method just as well as the indirect object, but since it doesn't matter where we put the code, we can just make an arbitrary choice and everything works fine, and this indeed is what the library does, for the most part without creating any problems or difficulties.

But what about that example of an apparently illogical action, PUT KNIFE IN TABLE? A table would normally be implemeted as a Surface or Platform, something we can put things on but not in, but perhaps PUT KNIFE IN TABLE could make sense if it means sticking the blade of the knife into the surface of the tabletop. This isn't the normal meaning of PUT IN, so it would need special handling, which would require overriding various methods on both objects to handle this special case, probably with slightly messy code in which object needs to test the identity of the other object (the knife needs to know whether the indirect object is the table and the table needs to know whether the direct object is the knife). We could probably handle this one special case without two much trouble, but if we had many more of them our neat object-oriented coding pattern might start to break down.

Now consider a game in which there are several objects that can cut other objects (their canCutWithMe property is true) and several objects that could be cut (their isCuttable property is true). The list of such objects might look like this:

isCuttable canCutWithMe
cakelawnmower
butterscissors
treeknife
lawnaxe
mrDastardlywireCutter
fencesword
ribbonsaw
envelopescythe
branchdagger

At this point, trying to handle everything by splitting the relevant code between the direct object and the indirect object becomes increasingly awkward. Cutting the tree with the lawnmower seems plainly illogical. Cutting the butter with the axe or the saw might be possible but messy. You could cut the lawn with either the mower or the scythe, but attempting to do so with the scissors would be highly inefficient at best. The saw or the axe must be the best tools for cutting the tree or the branch, but would the scythe be totally hopeless? We could hardly use the scissors for the task, but what about the sword? Each combination of objects seems to offer different possibilities with different effects. We've reached a point where it would feel so much easier if we had a neat way of defining what should happen with each combination of objects (or at least, each class of objects). This is where Multi-Methods could help, allowing us to write code like:

   checkCutWith(lawn dobj, scissors iobj)
   {
       "That would be theoretically possible but hopelessly inefficient. ";
   }

How Multi-Methods Can Help

To get the full story on Multi-Methods you may want to read the chapter on Multi-Methods in the TADS 3 System Manual. Here we shall restrict ourselves to what's needed to use Multi-Methods to handle TIActions in adv3Lite.

A Multi-Method is defined like a standalone function, but behaves rather like a method when it comes to inheritance. It takes the form:

   methodName(specifier1 arg1, specifier2 arg2,...)

The ellipsis after arg2, indicates that we can have as many arguments as we like, but for present purposes we're only interested in Multi-Methods that take two or three arguments.

For each argument, the specifier is a class to which the corresponding argument must belong or an object it must match if the multi-method is to be used, for example:

   checkCutWith(Thing dobj, lawnmower iobj)
   {
      "Cutting {the dobj} with the lawnmower would not be very practical. ";
   }
   
   checkCutWith(lawn dobj, lawnmower iobj} { }  

There are a couple of points to note here:

  1. Although these look rather like ordinary function definitions, unlike regular functions, multiple versions with the same can co-exist in the same game, provided they are distinguishable by their specifiers. That means we can have as many versions of checkCutWith as we like so long as they all have different combinations of specifier1 and specifier2.
  2. At run-time the interpreter will choose the version of checkCutWith() that gives the most specific match of dobj to specifier1 and iobj to specifier2, in the same way that it would call the most specific version of obj.method(), such lawn.makeCut() in preference to Thing.makeCut when the direct object is the lawn.
  3. When we call a multi-method (as opposed to defining it), we just supply its arguments in the ordinary way; for example we might call checkCutWith(cake, lawnmower); this would result in the checkCutWith(Thing dobj, lawnmower iobj) version of our multi-method being called, unless we'd defined another version of checkCutWith for which checkCutWith(cake, lawnmower) was a more precise match.

The effect of this example is that most attempts to cut cuttable objects with the lawnmower will be refused with the message "Cutting the whatever with the lawnmower would not be very practical" whereas cutting the lawn with the lawnmower would be allowed, since the more specific checkCuWith() multi-method overrides the more general one (and, in this case, does nothing at all, which allows the CutWith to proceed to the action stage).

Enabling Multi-Method TIA Handling

Multi-Method TIA handling is not enabled in the adv3Lite library by default, since many games may not make any use of it, and although it might not make all that much difference in practice, including multi-method TIA handling we're not going to use would just bloat the code and slow things down to no good purpose (although whether anyone would notice much on a modern computer is another matter).

There are two ways in which we can enable Multi-Method TIA handling in our game. The first is to include the multimethtia.t extension in our game (in the same way we'd include any other extension). This will then enable Multi-Method TIA handling for every TIAction defined in the library.

The other way, if we want to be more economical and we know we are only going to use Multi-Method TIA handling for a handful of actions, is to enable it on just those actions by using the MMIAction() macro, which we'd use like this:

  MMTIAction(PutIn);
  MMTIAction(CutWith);

All the multimethtia.t extension does is to apply this macro to every TIAction defined in the library.

Note that this macro can't be used twice for the same action without causing a compiler error, so if we start by using the MMTIAction() macro for individual TIActions and later decide to use the multimethtia.t extension, we'll need to remove all the MMTIAction macros from our own code, except those for new TIActions we've defined ourselves.

If we do define a new TIAction and we want to use multi-methods with it, we accordingly need to go through two steps:

  DefineTIAction(FrobWith);
  MMTIActinon(FrobWith);

It may we worth taking quite look at what the MMTIAction macro does. It's defined in the library as:

/*
 *   Modify a concrete TIAction to work with multimethods. This creates the base version of the
 *   three multimethods needed then sets the relevant methods of the TIAction to call the relevant
 *   multimethod.
 */   
#define MMTIAction(name) \
    verify ## name (Object dobj, Object iobj, Thing verobj) {} \
    check ## name (Object dobj, Object iobj) {} \
    action ## name (Object dobj, Object iobj) {} \
    modify name \
    mmVerify(dobj, iobj, verobj) { verify ## name (dobj, iobj, verobj); } \
    mmCheck(dobj, iobj) { check ## name (dobj, iobj); } \
    mmAction(dobj, iobj) { action ## name (dobj, iobj); }

To take our previous example, this means that MMTIA(FrobWith) will expand into the following code:

verifyFrobWith(Object dobj, Object iobj, Thing verobj) { }
checkFrobWith(Object dobj, Object iobj) { }
actionFrobWith(Object dobj, Object iobj) { }

modify FrobWith   
  mmVerify(dobj, iobj, verobj) { verifyFrobWith(dobj, iobj, verobj); }
  mmCheck(dobj, iobj) { checkFrobWith(dobj, iobj); }
  mmAction(dobj, iobj} { actionFrobWith(dobj, iobj); }

What's happened here is that we've created three new appropriately named multi-methods that match any objects at all in the direct object and indirect object slots (we'll come back to the Thing verobj parameter later). This ensures both that there are versions of these multi-methods for the libary to call, even if we don't go on to define any of our own, and that any versions we define will take precedence over these very general do-nothing ones, provided we always specify a pair of identifiers that are more specific than Object and Object (as in practice we almost certainly will).

The other thing we've done here is to tell the FrobWith action which multi-methods it should call at the verify, check and action stages.

Using Multi-Methods with TIActions

We should now take a look at how we might use these multi-methods at each of the verify, check and action stages. There might be a case for leaving the verify stage till last, since it is arguably the most complex, but we'll nevertheless take these three stages in their traditional order, both because what happens at each stage to some extent depends on what happened at the previous one, and because if we tackle the most complex case first the others should be all the easier to understand.

Verify

Verify mult-methods typically look like:

   verifyCutWith(cake dobj, lawnmower iobj, Thing verobj)
   {
       illogical('Trying to cut the cake with the lawnmower would be plainly ridiculous. ');
   }
   
   verifyCutWith(lawn dobj, lawnmower iobj, Thing verobj)
   {
       logicalRank(120);
       skip;
   }

Although mowing the cake with the lawnmower would be ruled out by the check multi-method defined above, we might consider it so ridiculous that it deserves to be ruled out at the verify stage, which not only decides whether the action can go ahead but guides the parser's choice of objects. Here, in addition to ruling out cutting the cake with the lawnmower, we're telling the parser that the cake and the lawnmower would be a poor choice of objects in the direct and indirect object roles of the CutWith action.

On the other hand, the lawn and the lawnmower would be a particularly good choice of objects in the direct and indirect object roles of the CutWith action, so we want to boost the logicalRank of this combination. To make sure we achieve this we add the skip macro to skip the dobjForVerify() and iobjFor(Verify) routines on the lawn and the mower respectively, in case they would otherwise have intervened with a lower logical rank, which would then have taken precedence.

At this point we should explain the seemingly mysterious Thing verobj parameter, which absolutely needs to be there in precisely that form, although we can use the tvo macro to abbreviate it.

   verifyCutWith(cake dobj, lawnmower iobj, tvo)
   {
       illogical('Trying to cut the cake with the lawnmower would be plainly ridiculous. ');
   }
   
   verifyCutWith(lawn dobj, lawnmower iobj, tvo)
   {
       logicalRank(120);
       skip;
   }

But why is this third parameter needed? The short answer is that we wouldn't be able to use macros like illogical() and logicalRank() without it. Prior to the introduction of mult-method TIAction handling to the adv3Lite library these macros were defined thus:

#define illogical(msg) \
    gAction.addVerifyResult(new VerifyResult(30, msg, nil, self))

#define logicalRank(score) \
    gAction.addVerifyResult(new VerifyResult(score, '', true, self))

They were defined this way because they were defined to be called in the verify() method of one of the objects (direct or indirect) involved in the action, and to add themselves to the table of verify results. But this meant they couldn't be used in a multi-method, because, there is no self object in the context of a multi-method.

They (and the other verify macros) have accordingly been redefined:

#define illogical(msg) \
    gAction.addVerifyResult(new VerifyResult(30, msg, nil, verobj))

#define logicalRank(score) \
    gAction.addVerifyResult(new VerifyResult(score, '', true, verobj))

While the Thing class now defines:

   verobj = self 

This means that these macros can continue to be used in exactly the same way with exactly the same effect in the verify routines defined on the direct and indirect objects of the action. But is also means that they can now also be used in verify multi-methods provided there is a parameter called verobj for them to relate to.

But what object is passed as the verobj argument? The answer is that the verifyCutWith() multi-method will be called twice, once with the direct object as the verobj argument and once with the indirect object, so that a call to illogical(), logicalRank(), or any other such verify macro will add the same verify result for both the direct object and the indirect object — and this just is what we want, for it tells the parser that both objects are either a good or a bad fit for the action in question when used in combination.

Finally, we should take a closer look at the skip macro. What it does is to throw a new SkipSignal exception, which in this case causes execution to skip over calling the verify routines on the direct and indirect objects of the action (it does not, however, skip over calling the verifyPreCondition() methods of any of the PreConditions defined on the direct and indirect objects, since the chances are that these will still be applicable). So when should we use skip here? That depends, of course, on what we want to achieve, but as a rough rule of thumb, it's a good idea to use skip when we want to ensure that our verify multi-method allows the action to go ahead, but to omit skip when we want it to stop the action (in which case we may as well keep the other possible reasons for stopping the action to go ahead). Some care is needed, however, since we don't want to write a verify multi-method that could have disastrous consequences, such as:

  verifyPutIn(dobj Thing, iobj packingCrate, tvo)
  {
     logicalRank(110);
     skip;
  }

At first sight this may look like a good way to make the packing crate a specially good choice for putting things but you only have to try the command PUT CRATE IN CRATE (or even PUT WOODEN IN CRATE if both the crate and a chair are described as 'wooden') to discover why it isn't (spoiler alert: allowing you to put the crate in itself will cause a spectacular stack overflow error). The moral of this story is that we should ensure that using skip doesn't risk skipping sanity checks we may still need.

Check

Compared with using verify multi-methods, using check ones is relatively simple. We no longer need the third parameter, so provided we haven't ruled things out at the verify stage we could, for example, write check multi-methods like:

checkPutIn(Platform dobj, Container iobj)
{
   "{The dobj} {is} obviously too big to fit {in iobj). ";
}

checkPutIn(Platform dobj, Container packingCrate)
{   
}

This would cover a situation in which the only object in our game that's large enough to accommodate Platforms (which must be fairly large if actors can get on them) is the packing crate. We have already seen examples of how it might be used to allow or disallow various combinations of objects for CutWith, e.g.:

checkCutWith(Actor dobj, axe iobj)
{
   "That would be excessively violent. ";
}

Further point to note here are:

Action

In principle, action multi-methods act much like check ones, although they may be a bit trickier to implement. In particular, just as we can in verify and check, we can use skip to skip over what the library would have done in its action handling on the direct and indirect objects of the action. Whether or not this is a good idea depends on whether we want to add something to the libary's standard handling of the command or replace it. If we want to add to it, it's important to be aware that whatever we define in our action multi-method will execute before the library's usual handling.

One way we can use a multi-method to add to the libary's handling is simply to display some text the library would not otherwise have displayed, for example:

  actionPutIn(delicateVase dobj, Container iobj)
  {
     "{I} very carefully lower(s/ed} the vase into {the obj}. ";
  }

This will work because if an action method displays any text, the action will not be reported again at the report stage for the same direct object. It's very unlikely that a PutIn action such as this would be triggered as an implicit action, but if you wanted to make assurance double sure you could instead write:

  actionPutIn(delicateVase dobj, Container iobj)
  {
     actionReport('{I} very carefully lower(s/ed} the vase into {the obj}. ');
  }

Tbings are likely to be more complicated in the case of our CutWith example, where what happens will very much depend on the particular pair of objects involved. For example, we might write:

actionCutWith(cake dobj, knife iobj)
{
   "You caredfully cut the cake with the knife, and then offer 
   the first slice to the bride. ";
   cake.makeCut() // assume this is a custom method you've defined on the cake.
   knife.makeDirty(cake); // likewise on the knife.
   
   skip;
}

actionCutWith(cake dobj, sword iobj)
{
   "Carefully wielding the sword with both hands, you just manage to cut into 
    the cake with it, although it results in quite a clumsy slice. As you habd
   the first slice to the bride, Mr Dastardly looks daggers at you, plainly 
   suspecting, not without justice, that you're just showing off. ";
   cake.makeCut() // assume this is a custom method you've defined on the cake.
   sword.makeDirty(cake); // likewise on the knife.
   
   skip;
}

actionCutWith(mrDastardly dobj, knife iobj)
{
   "You accidentally on purpose let the knife slip and nick Mr Dastardly's hand. 
   Dastardly at once clutches his <i>very</i> minor wound and screams 
   in pain before running from the room, thereby demonstrating what a coward he really is 
   --- as well as getting him out of the way.<.reveal cut-dastardly> ";
   
   mrDastardly.moveInto(nil);
   
   skip;
}

The skips may be redundant here since the library doesn't define anything for the action stage of CutWith, but they shouldn't do any harm, so we may as include them to be on the safe side. Of coure if you'd provided your own action handling for CutWith on Thing and/or any other classes you'd need to consider whether you wanted to skip it or not.

This incidentally raises another issue: what if we want the same thing to happen if the player character cuts Mr Dastardly with some other cutting implement, such as the scissors? While we might disallow the sword as being too blatant and the axe as too violent, the we might consider the scissors to be another viable option, but there's no way we can specify identifier as a list but we'd probably rather not have to duplicate our code.

There are two ways we could deal with this; the first is to define a makeCut(obj) on the mrDastardly object that does all the work and then define:

actionCutWith(mrDastardly dobj, knife iobj)
{
   dobj.makeCut(iobj);
   skip;
}

actionCutWith(mrDastardly dobj, scissors iobj)
{
   dobj.makeCut(iobj);
   skip;
}

Although this may feel less than ideal. The other is to define a custom class such as:

DomesticCuttingImplement: Thing
  canCutWithMe = true 

And then assign both the knife and the scissors to that class. Then we can write:

actionCutWith(mrDastardly dobj, DomesticCuttingImplement iobj)
{
   "You accidentally on purpose let {the iobj} slip and nick Mr Dastardly's hand. 
   Dastardly at once clutches his <i>very</i> minor wound and screams
   in pain before running from the room, thereby demonstrating what a coward he really is
   --- as well as getting him out of the way.<.reveal cut-dastardly> ";
   
   mrDastardly.moveInto(nil);
   
   skip;
}

To my mind the latter seems preferable, both because it keeps all the relevant code in one place and because it becomes simpler to extend it to other objects, if say, we later wanted to add a letter opener into the mix, we could just define it to be of the DomesticCuttingImplement class. Moreover, since TADS 3 allows multiple inheritance, we can define the various cutting objects as belonging to as many classes as we need to fit the identifier 2 slots we want to implement in our multi-methods. We can, of course, do the same for slots we want to match at the check and verify stages, should we wish to group objects together there as well.

Remap, PreCond and Report

The adv3Lite library allows for the verify, check and action stages of TIActions to be handled by multi-methods, but not the remap, precond and report stages.

There would be very little point in handling the report stage with a multi-method. The adv3Lite library uses the report stage to provide default reports and summarize an action that has acted on multiple direct objects. It therefore makes sense for it to be handled in the report() method of the direct object. The message it produces is in any case not dependent on any particular combination of direct and indirect objects, since this would go against the entire purpose of a report method, which is to provide a brief standardized report for all the direct objects acted on. Any custom messages relating to particular pairs of object will have been produced at the action stage.

In adv3lite the remap stage replaces the direct or indirect object of an action with another object, for example, diverting the action from a desk to its drawer for certain actions such as PutIn or Search. It's unclear that this could be usefully varied for different pairs of objects. If PUT X IN DESK normally results in X being put in the drawer, players will probably find it unreasonably inconsistent if PUT KNIFE IN DESK results in the knife being plunged into the desktop.

PreConditions are normally best defined on the objects to which they relate. There are few situations in which this is likely to vary according to the combination of objects involved (as opposed to individual objects). To able to put x in y would normally seem to require the actor to first hold x and then being able to reach y. If the actor has a magical ability to teleport one object into another, that seems likely to apply to every object rather than particular pairs of objects. If I have magic box I can put things in even when the box is out of reach, I can just remove the touchObj PreCondition from the magic box. The situation could be different if the magic box allows me to move objects into it without needing to hold or even them, because we would then need to remove the objHeld PreCondition from every portable object in the game if and only if the indirect object is the magic box; but then I may need a new TeleportTo action for that (see below).

It is, of course, possible that a game will need to implement something that's an exception to these norms, but in that case the exceptional handling might be better implemented by means of Doers, which brings us to our final topic.

Multi-Methods vs Doers

There are some similarities between Multi-Methods and Doers in that each can be selected on the basis of the action and combination of objects/and or classes they're designed to work with and then execute some custom code. Doers have the additional advantage that they can specify the conditions under which they should be active and have more flexibility in specifying the action(s) and objects they apply to. So what is the advantage of using Multi-Methods?

The main advantages of Multi-Methods over Doers is that Multi-Methods are more tightly integrated into the normal action-handling cycle than Doers and that they are usually easier to write. Defining a Multi-method is more concise than definining an equivalent Doer. It may also require less work of the game author, since if the entire handling of an action has to be coded in the exec(curCmd) or execAction(curCmd) method of a Doer, the game author has to take responsibility for carrying out all the sanity checks and notifications the normal action handling would normally take care of, whereas this isn't something we need to worry about when we use multi-methods. The main exceptions here are the cases that Doers are best fitted to handle: stopping an action in its tracks or changing one action into another through the use of doInstead().

This points to one possible way to get the best of both worlds. Consider the scenario in which we might want PUT IN to work in a totally non-standard way through the player character's magical abilities or advanced technology (say, allowing the pc to teleport objects into special containers without touching them and even if the special container remain closed). The neatest way to handle this might be to define a special TeleportTo action, which needn't necessarily have to have an associated VerbRule, and then use a Doer to deflect a PutIn action to it under specified circumstances:

Doer 'put Thing in SpecialContainer'
    exec(curCmd)
    {
       doInstead(TeleportTo, gDobj, gIobj);
    }
    
    when = (gActor.hasSpecialAbility)
;

We can then define dobjFor(TeleportTo) and iobjFor(TeleportTo) blocks on Thing (and presumably SpecialContainer in the latter case) that reflect the different rules of this magical/technically advanced action. For example we may decide there should be no PreConditions at all. We can also write some basic verify rules on Thing to rule out non-portable objects being teleported (allowing them to be could cause havoc) and not allowing anything to be teleported to itself. If we want teleporting to work differently for different pairs of direct and indirect objects, we could even enable multi-method TIAction handling on our new TeleportTo action and write a series of TeleportTo multi-methods. This would then allow PUT X IN Y to act in special ways without disrupting the normal functioning of PUT IN when the actor doesn't have the necessary special ability and/or the indirect object of the PUT IN command doesn't belong to the SpecialContainer class we'll have devised for the purpose.