A TADS3/adv3 module providing low-level NPC target-seeking behaviors

A TADS3/adv3 module for simple NPC target-seeking behaviors: targetEngine github repo.

This provides agendas for three basic NPC behaviors: move an NPC to a location, have an NPC take an object, and have an NPC examine something.

To have an NPC use the target engine logic, just declare useTargetEngine = true on the NPC:

      alice: Person 'Alice' 'Alice' 
              "She looks like the first person you'd turn to 
              in a problem. "
              isHer = true
              isProperName = true

              useTargetEngine = true
      ;

Now if you want Alice to take the pebble, you could use:

        // Tells alice to >TAKE the pebble. 
        alice.obtain(pebble);

By itself this won’t send Alice off to find the pebble, it just tells the actor to attempt to take the pebble when and where they see it. You can give an NPC multiple targets, so if you want Alice to gather the ingredients for a BLT, you can use something like:

        alice.obtain(bacon);
        alice.obtain(lettuce);
        alice.obtain(tomato);

…assuming all three objects are defined in the game. This will tell Alice to attempt to take each of the listed items when she encounters them.

The target engine currently provides the following behaviors:

  • moveTo(loc) Attempts to move the NPC to the given location
  • obtain(obj) If the NPC finds themselves in the same location as the object, they will attempt to obtain it
  • observe(obj) If the NPC finds themselves in the same location as the object, they will attempt to examine it

Each of these methods accepts a callback function as the optional second argument. The callback will be called when the target is reached/obtained/completed, with the argument boolean true on success or nil on failure.

This is the first part of a system of more general NPC goal-seeking I’m working on. This is intended to hold just the low-level nuts-and-bolts stuff—no decision-making, just the in-game mechanisms for accomplishing individual tasks.

7 Likes

You are making me want to switch back to adv3 from adv3lite. Nice work!

1 Like

Been working on more stock “building block” agends for building more complex, reactive NPC behaviors. This one’s Explore.

Explore

Properties

  • agendaOrder = 180

    All the targetEngine agendas have orders between 100 and 199. Explore’s is after all the others except RandomWalk.

    This means that if an NPC is exploring and has been told to .obtain() or .observe() things that will happen automatically during exploration.

  • depthFirst = true

    By default exploration will use a depth-first approach. In brief:

    • NPC will look check each exit from their current location and make a note of any they haven’t visited
    • If there are one or more unexplored exits in the current room, one will be selected and the NPC will attempt to move through it. The first unvisited exit, sorted by the order given by Direction.allDirections, will be picked.
    • If there is no unexplored exit in the current location, the agenda will select the most recent room observed (in the first step above) that has unvisited exits. It will then call the Move agenda to attempt to move the NPC to that room.

    These steps will be iterated until there are no known unvisited exits.

    If depthFirst = nil, then a breadth-first approach will be used. It is, briefly:

    • Make a note of unvisited exits as above
    • Pick the oldest noted unvisited exit
    • Path to the room the exit is in
    • Take the exit

    Both approaches should work for non-pathological maps, but in general depth-first will involve less backtracking. Breadth-first probably makes more sense if the goal isn’t to complete the map (that is, to visit every unvisited location) but to look for something that’s believed to be near the starting location.

Basic Usage

In general all that’s needed to use the Explore agenda is to declare useTargetEngine = true on the NPC:

     modify alice useTargetEngine = true;

…and then somewhere in the code trigger the agenda with:

     alice.explore();

Once started the agenda will automatically terminate when it runs out of unexplored exits. To stop it before then you can use:

     alice.clearExplore(true);

Notes

The main “gotcha” is if the NPC starts out knowing of no unvisited exits—like if they’ve explored previously or if they’re initialized with certain locations flagged as seen before they’ve “really” visited them. That means when the agenda starts they’ll look around, have no list of places to start exploring, and immediately give up.

To prevent this, if the agenda is started and there’s nothing in the list of unexplored exits (including any from checking the current location) then:

  • We iterate over every Room instance, skipping any that the Actor hasn’t seen
  • For each Room the actor has seen, we check it for unvisited exits, and add them to our list to explore
  • When we’re done we set a flag that we check when we start this process, so by default we only do this check once per NPC/agenda

This doesn’t give the NPC any unfair psychic knowledge of the environment (it only checks rooms the NPC already knows about) but it prevents the failure-at-startup when there is no other starting list of places to explore.

Ordering

By default the agendaOrder is lower than most of the other targetEngine agendas, so if the actor has also been told to obtain or observe objects they’ll automagically do that while exploring.

Dependencies

Note that the Explore agenda requires the memoryEngine module, as will most additional/more complicated agendas that will (hopefully) follow.

3 Likes

There’s now a devel branch for stuff I’m actively working on (should be better about this for repos that I’ve made pubic). It includes a few new agenda types that’ll end up merged into main, hopefully:

Search

The Search agenda tells the NPC, basically, to toss any room they wander into. That is, they try to identify stuff that might need to be interacted with in order to locate something they might be looking for. Currently, that just involves trying to open closed containers, but the agenda has an isSearchable(obj) method for adding additional logic.

The Search agenda doesn’t directly do anything, it’s basically just meta-logic for managing the Open and Unlock agendas, below.

Specifically, if the search agenda is active and the NPC observes an in-scope openable container that isn’t open, they’ll call the Open agenda on it.

An Actor (that is unsing targetEngine) can be told to start searching by calling actorName.search([something]) where [something] is essentially used as an ID for the task. Just true will work if you want it all-or-nothing, or a string ID could be used if multiple things might want to turn on searching and you don’t want the completion of one to possibly stop searching when it wants to continue for some other reason.

Open

If the Open agenda is active, the actor will attempt to open a targetted openable container if it is in scope. Like Obtain (and most of the other agendas) this will not cause the actor to seek the container, just attempt to interact with it if they encounter it.

If the container is locked, the agenda will:

  • remove the container from its target list
  • invoke the Unlock agenda with the container as its target.
  • invoke the obtainCustom() agenda with a filter function that matches plausible keys for the container

Unlock

When the Unlock agenda the actor will attempt to unlock a container on its target list if:

  • it is currently in scope
  • the actor is holding a key which is plausible for it (checked using LockableWithKey.keyIsPlausible()
  • the actor hasn’t already tried all plausible keys they’re carrying on the container

If the agenda is successful in unlocking the container, it will add the container back to the Open agenda.

ObtainCustom

The ObtainCustom agenda is like the vanilla Obtain agenda, but instead of matching a specific object, it uses a filter function to determine if an object is a target.

For example, the Open agenda calls:

   getActor().obtainCustom(bind(&matchKey, self, t.target));

…where getActor() will return the Actor who owns the agenda, matchKey() is a method on the Open agenda, self is the Open agenda (so the bind fucntion will call matchKey() on the agenda instance), and t.target is the container the agenda just tried and to open and discovered it was locked.

The Open.matchKey() method looks like:

        matchKey(cont, obj) {
                if(!obj.ofKind(Key))
                        return(nil);
                if(obj.getCarryingActor() == getActor())
                        return(nil);
                if(cont.keyIsPlausible(obj))
                        return(true);
                
                return(nil);
        }

…so it returns boolean true for Key instances that are plausible for the container that the actor isn’t already carrying.

In Action

The searchTest demo has a searchable box which is closed and unlocked, a lockable box wich is closed and locked, a key that unlocks the lockable box, and an NPC Alice that has the Search agenda. The >FOOZLE command that starts the agenda:

This demo provides a >FOOZLE command that triggers Alice's searchTest agenda.

Alice's Room
This is Alice's starting room.

You see a searchable box, a lockable box, and a key here.

Alice is here in the conversation ready state.

>foozle
Alice is now searching.

>z
Time passes...

Alice opens the searchable box.

>z
Time passes...

Alice attempts to open the lockable box but it seems to be locked.

>z
Time passes...

Alice takes the key.

>z
Time passes...

Alice unlocks the lockable box.

>z
Time passes...

Alice opens the lockable box.
>z

Time passes...

Alice idles.

>

Still in the devel branch so there’s probably some bugs. And I want to extend this to handle locked doors as well as containers.

But my overall goal for the WIP is to be able to have the player delegate tasks like “go to the library and find their copy of the Necronomicon” and that kind of thing, and this is decdent-sized chunk of it.

3 Likes

Couple of updates.

Actor.find()

Added a pseudo-agenda, find. Basic usage is like most of the “real” agendas:

     // Send Alice off in search of the pebble
     alice.find(pebble);

…and it can be cleared with…

     // Stop Alice's pebble hunt
     alice.clearFind(pebble);

Unlike obtain(), find() does tell the actor to actively seek out the given object (instead of just picking it up if they happen to be in the same scope as the target).

The programming interface, at least on Actor, works the same as the targetEngine agendas. Under the hood they’re just “plain” methods on Actor that handle juggling other “real” agendas.

At present the find() logic just invokes explore(), search(), and obtain(). When invoking the Obtain agenda it passes a callback that will be called on success or failure, and that’s used to clear the other agendas.

Because the only movement is provided by Explore, this is currently a little brittle—it works well the first time an NPC is asked to find something, but if they’ve already explored most of the map they won’t re-check anyplace they’ve already been. I’ve got a bunch of logic for memory-based searching (that is, “intelligently” re-visiting previously explored areas) but that’s currently ugly bespoke code on individual NPCs that needs to be prettified for inclusion in the module.

targetEngineMethods Macros

Previously every time I added a new agenda I was “manually” adding the methods on Actor and TargetEngine that handle invoking the agenda(s). This involved touching four separate bits of code. Which wasn’t a big deal (because I’m not adding that many new agendas). But it was still vaguely bothering me so I implemented the add-the-stuff logic as a macro, targetEngineMethods.

Sample usage:

     // Set up the Explore agenda
     targetEngineMethods(explore, Explore)

This:

  • creates TargetEngine.explore() and TargetEngine.clearExplore() with the standard TargetEngine add/remove target usage (calling _setTarget() and _clearTargetObj() under the hood)
  • creates Actor.explore() and Actor.clearExplore(), which are wrappers that call the corresponding methods on the actor’s TargetEngine instance if they have one (and failing cleanly if they don’t)
  • defines exploreAgendaClass = Explore on TargetEngine, to make it easier to subclass and have per-actor agenda classes
  • adds exploreAgendaClass to the list in TargetEngine._agendaList, which enumerates which agendas to create for actors using targetEngine by default

All the existing agendas have been updated to use the macro.

1 Like