Design pattern for NPC scripting/custom action messages

Most TADS3 code examples I’ve seen handle NPC scripting more or less by circumventing the normal verify()/check()/action() logic the player goes through, and instead just directly doing whatever the script wants to accomplish. For example, if an NPC is meant to take an object and say something while doing it, there will be something like:

someAction() {
     "Bob notices a widget and quickly grabs it. ";
     widget.moveInto(bob);
}

…or whatever. That’s fine, but I’m interested in something that works with more “autonomous” NPC behavior, where actions might be coming from, for example, a simple FSM in an agenda, and are being executed in the form of newActorAction() calls.

The approach I’ve worked out is creating a subclass (or several subclasses) of npcActionMessages and replacing specific action messages with hooks to call a method in the NPC. Here’s a simple example:

#charset "us-ascii"
#include <adv3.h>
#include <en_us.h>

startRoom:      Room 'Void'
        "This is a featureless void. "

        // Simple room daemon that will reset the pebble's position 
        // after it's been in anyone's inventory for a couple turns
        pebbleCounter = 0
        pebbleFlag = nil
        roomDaemon() {
                if(pebbleFlag == true) {
                        roomDemonCounter();
                } else {
                        roomDemonReset();
                }
        }
        roomDemonReset() {
                if(pebble.location == self)
                        return;
                pebbleFlag = true;
                pebbleCounter = 3 + rand(4);
        }
        roomDemonCounter() {
                local holder;

                if(pebbleCounter > 0) {
                        pebbleCounter -= 1;
                        return;
                }

                holder = pebble.location;
                gMessageParams(holder, pebble);
                "<.p>
                The room daemon cackles quietly to itself as it moves
                        {the pebble/him} from {the's holder/her}
                        inventory and into the room.<.p> ";
                pebble.moveInto(self);
                pebbleFlag = nil;
        }
;
+ pebble: Thing 'small round pebble' 'pebble'
        "A small, round pebble. "
        isEquivalent = true
;

class PebbleActor: Actor
        willNotLetGo(obj) {
                gMessageParams(self, obj);
                return('{The self/he} {won\'t} let {you/him} have
                        {that obj/him}. ');
        }
        initializeThing() {
                local a;

                inherited();
                a = new pebbleAgenda();
                a.location = self;
                self.addToAgenda(a);
        }
;
class pebbleAgenda: AgendaItem
        agendaOrder = 99
        isReady() {
                return(inherited()
                        && (self.location.contents.indexOf(pebble) == nil));
        }
        invokeItem() {
                if(rand(100) > 25)
                        return;
                newActorAction(self.location, Take, pebble);
        }
;
class pebbleActionMessages: npcActionMessages
        willNotLetGoMsg(holder, obj) {
                return(holder.willNotLetGo(obj));
        }
;

alice: PebbleActor 'alice' 'Alice'
        "She looks like the first person you'd turn to in a problem. "
        isHer = true
        isProperName = true
        location = startRoom
        getActionMessageObj = new aliceActionMessages
        willNotLetGo(obj) {
                gMessageParams(self, obj);
                return('<<gActor.name>> tries taking a pebble from
                        {The self/him}.<.p>
                        <q>Nope.  Mine,</q> {The self/he} say{s},
                        <q>All mine.</q>');
        }
;
aliceActionMessages: pebbleActionMessages
        okayTakeMsg() {
                if(gDobj.ofKind(pebble)) {
                        return('<q>I spy with my little eye</q> says Alice,
                                <q>A pebble!</q>.  She gleefully takes the
                                pebble. ');
                }
                gMessageParams(gActor, gDobj);
                return('{The gActor/he} take{s} {the obj/him}. ');
        }
;

bob:    PebbleActor 'bob' 'Bob'
        "He looks like a Robert, only shorter. "
        isProperName = true
        location = startRoom
        getActionMessageObj = new bobActionMessages
        willNotLetGoList = [
                '<<gActor.name>> reaches for one of
                        {the\'s self/her} pebbles.<.p>
                        <q>Never!</q> {The self/he} says{s}, laughing,
                        <q>The pebbles shall all be mine!</q>',
                '<<gActor.name>> tries taking one of {the\'s self/her}
                        pebbles.<.p>
                        <q>Avast, varlet!</q> {The self/he} yell{s},
                        pulling away, <q>You\'ll never have my pebbles!</q>',
                '{The gActor/he} sidles up to {The self/him} with an
                        innocent look on {its gActor/her} face.<.p>
                        <q>Don\'t even think about it, </q> {The self/he}
                        growls. '
        ] 
        willNotLetGo(obj) {
                gMessageParams(self, obj, gActor);
                return(self.willNotLetGoList[rand(self.willNotLetGoList.length()) + 1]);
        }
;
bobActionMessages: pebbleActionMessages
        okayTakeMsg() {
                if(gDobj.ofKind(pebble)) {
                        return('<q>Ha!</q> says Bob, <q>A pebble!</q>.  Bob
                                greedily takes the pebble. ');
                }
                gMessageParams(gActor, gDobj);
                return('{The gActor/he} take{s} {the obj/him}. ');
        }
;

me:     Actor
        location = startRoom
;

versionInfo:    GameID
        name = 'sample'
        byline = 'nobody'
        authorEmail = 'nobody <foo@bar.com>'
        desc = '[This space intentionally left blank]'
        version = '1.0'
        IFID = '12345'
;
gameMain:       GameMainDef
        initialPlayerChar = me
;

This is a simple drama involving two NPCs, a slightly kleptomaniacal room daemon, and a single pebble. The long-suffering Alice and Bob both covet the pebble, and each have an agenda that sees them attempting to take it, wherever it is, with a certain probability each turn. The room daemon notices whenever the pebble has been in anyone’s inventory for more than a couple turns and returns it to the room. So it’s an endless cycle of one NPC taking the pebble and then the other NPC trying and failing to take it from them.

If this toy problem is all I was worried about it would be easier, probably, to just implement all the actor-specific behaviors (in this case just flavor text for the actions) in a bunch of conditional statements in, i.e. dobjFor(Take) on the pebble and so on. Or just “fake” the behavior in the agenda by having the invokeItem() do what my someAction() does above: just display text and force actions without going through newActorAction().

But in the “real” code I’m writing I actually want to be able to use all the verify() and check() logic of the involved objects (without having to re-implement it in the NPC scripting).

Is there a cleaner/more straightforward way to do this sort of thing? Basically it’s fairly easy to change the behavior of objects in an action by for example twiddling the object’s dobjFor() corresponding to that action. But there doesn’t seem to be a similar thing for changing the behavior of an NPC when they are the one initiating an action.

2 Likes

I don’t know of any prepackaged mechanisms for that… it sounds like something you might have to set up on your own. It would be nice to have a TADS library/extension that handles some of the issues you’ve been looking to implement…

Most of my NPC autonomy so far has been handled in Agendas with code statements and double strings rather than newAction, so I haven’t had to tinker yet with what you’re talking about…

The example is interesting, because there’s a bug somewhere: if the PC preempt both NPC taking the pebble, both actor’s agendas apparently cease to work (tested with frob and patient entering of z’s…), even after dropping the pebble or the intervention of the daemon (note, there happened both dropping and daemon intervention…)

whose point to another interesting perspective to debate on the point:
AFAIU, I don’t know how to effectively write NPC agendas/scripts reactive to player’s interactive interferencing (e.g. the classical “distract the wandering guard” puzzle) with T3.

Best regards from Italy,
dott. Piergiorgio.

Yeah, apologies. I put together the example to illustrate something that I was trying to do in a “real” game, and I didn’t do much playtesting on the example beyond making sure it illustrated the thing I was asking about.

The error(s) are because the player’s me object isn’t an instance of PebbleActor. So when Alice or Bob try to take the pebble from the player, there’s no willNotLetGo() on the pebble holder to call via the pebble taker’s willNotLetGoMsg(). And so the interpreter chucks a wobbly.

Here’s a slightly improved version of the example code. It prevents actors who are not instances of PebbleActor from taking the pebble, it prevents PebbleActors from attempting to take a pebble they can’t see, and it adds the PebbleActor class to the player object, along with a willNotLetGo() that displays an appropriate message when Alice or Bob try to filch a pebble you’re holding.

#charset "us-ascii"
#include <adv3.h>
#include <en_us.h>

startRoom:      Room 'Void'
        "This is a featureless void. "

        // Simple room daemon that will reset the pebble's position 
        // after it's been in anyone's inventory for a couple turns
        pebbleCounter = 0
        pebbleFlag = nil
        roomDaemon() {
                if(pebbleFlag == true) {
                        roomDemonCounter();
                } else {
                        roomDemonReset();
                }
        }
        roomDemonReset() {
                if(pebble.location == self)
                        return;
                pebbleFlag = true;
                pebbleCounter = 3 + rand(4);
        }
        roomDemonCounter() {
                local holder;

                if(pebbleCounter > 0) {
                        pebbleCounter -= 1;
                        return;
                }

                holder = pebble.location;
                gMessageParams(holder, pebble);
                "<.p>
                The room daemon cackles quietly to itself as it moves
                        {the pebble/him} from {the's holder/her}
                        inventory and into the room.<.p> ";
                pebble.moveInto(self);
                pebbleFlag = nil;
        }
;
+ pebble: Thing 'small round pebble' 'pebble'
        "A small, round pebble. "
        isEquivalent = true
        dobjFor(Take) {
                check() {
                        if(!gActor.ofKind(PebbleActor)) {
                                "{You/he} {have} no interest in pebbles. ";
                                exit;
                        }
                }
        }
;

class PebbleActor: Actor
        willNotLetGo(obj) {
                gMessageParams(self, obj);
                return('{The self/he} {won\'t} let {you/him} have
                        {that obj/him}. ');
        }
        initializeThing() {
                local a;

                inherited();
                a = new pebbleAgenda();
                a.location = self;
                self.addToAgenda(a);
        }
;
class pebbleAgenda: AgendaItem
        agendaOrder = 99
        isReady() {
                return(inherited()
                        && (self.location.contents.indexOf(pebble) == nil));
        }
        invokeItem() {
                if(!self.location.canSee(pebble))
                        return;
                if(rand(100) > 25)
                        return;
                newActorAction(self.location, Take, pebble);
        }
;
class pebbleActionMessages: npcActionMessages
        willNotLetGoMsg(holder, obj) {
                return(holder.willNotLetGo(obj));
        }
;

alice: PebbleActor 'alice' 'Alice'
        "She looks like the first person you'd turn to in a problem. "
        isHer = true
        isProperName = true
        location = startRoom
        getActionMessageObj = new aliceActionMessages
        willNotLetGo(obj) {
                gMessageParams(self, obj);
                return('<<gActor.name>> tries taking a pebble from
                        {The self/him}.<.p>
                        <q>Nope.  Mine,</q> {The self/he} say{s},
                        <q>All mine.</q>');
        }
;
aliceActionMessages: pebbleActionMessages
        okayTakeMsg() {
                if(gDobj.ofKind(pebble)) {
                        return('<q>I spy with my little eye</q> says Alice,
                                <q>A pebble!</q>.  She gleefully takes the
                                pebble. ');
                }
                gMessageParams(gActor, gDobj);
                return('{The gActor/he} take{s} {the obj/him}. ');
        }
;

bob:    PebbleActor 'bob' 'Bob'
        "He looks like a Robert, only shorter. "
        isProperName = true
        isHim = true
        location = startRoom
        getActionMessageObj = new bobActionMessages
        willNotLetGoList = [
                '<<gActor.name>> reaches for one of
                        {the\'s self/her} pebbles.<.p>
                        <q>Never!</q> {The self/he} says{s}, laughing,
                        <q>The pebbles shall all be mine!</q>',
                '<<gActor.name>> tries taking one of {the\'s self/her}
                        pebbles.<.p>
                        <q>Avast, varlet!</q> {The self/he} yell{s},
                        pulling away, <q>You\'ll never have my pebbles!</q>',
                '{The gActor/he} sidles up to {The self/him} with an
                        innocent look on {its gActor/her} face.<.p>
                        <q>Don\'t even think about it, </q> {The self/he}
                        growls. '
        ] 
        willNotLetGo(obj) {
                gMessageParams(self, obj, gActor);
                return(self.willNotLetGoList[rand(self.willNotLetGoList.length()) + 1]);
        }
;
bobActionMessages: pebbleActionMessages
        okayTakeMsg() {
                if(gDobj.ofKind(pebble)) {
                        return('<q>Ha!</q> says Bob, <q>A pebble!</q>.  Bob
                                greedily takes the pebble. ');
                }
                gMessageParams(gActor, gDobj);
                return('{The gActor/he} take{s} {the obj/him}. ');
        }
;

me:     PebbleActor
        location = startRoom
        willNotLetGo(obj) {
                gMessageParams(self, obj, gActor);
                return('{The gActor/he} tries taking a pebble from
                        you, but you fend {it gActor/her} off. ');
        }
;

versionInfo:    GameID
        name = 'sample'
        byline = 'nobody'
        authorEmail = 'nobody <foo@bar.com>'
        desc = '[This space intentionally left blank]'
        version = '1.0'
        IFID = '12345'
;
gameMain:       GameMainDef
        initialPlayerChar = me
;

Thanks for the unexpected after-betatest revision of your technical example !!

In the meantime, inspired by your purple prose, I have given alice some “feminine wile” (female wiles ARE a sophisticated form of low cunning, after all…) against bob.

I’m thinking about PC/alice interaction, and, if you know the “recruiting Yuffie” puzzle of original Final Fantasy VII, you should understand the interesting counter-wile mechanism for the player.

Best regards from Italy,
dott. Piergiorgio.