Talking with two NPCs simultaneously

Hi. I have an adv3 game in which I have two separate NPCs, but at some point they are together in office doing some paperwork when player will try to talk to them. In this situation the player effectively talks to both of them simultaneously - the dialogues are written in a way that when player asks or tells something, one of the character replies and the other one seconds with some additional sentence. It doesn’t matter to which NPC the player starts talking, they act together as if they were one character.

Temporarily I’ve solved this problem by including the same converasation objects into both characters. I’ve written the common part of conversation into a separate file and used #include directive to place it under both character’s InConversationState for this conversation. But this is not very good solution, it has many quirks. First of all the player actually talks to only one of the characters so the second one is still in ConversationReadyState and when player examines the NPC than it is not described as talking to you. Second it is not desirable for the player to choose between the two when he says hello and game insist to know which one the player wants to talk to. Or when the player asks one NPC about something and then the other one about something else it shouldn’t be treated as end of one conversation and start of another with dedicated goodbye and hello messages.

One could think about replacing them by only one Actor object which pretends to be both, but that would present another set of chalenges such as how to make it look as two when player examines one of them and so on.

I’m not looking so much for code samples, but rather for some visdom or experiences with similar situation. Did someone implemented something like this? What would be the best strategy to overcome problems? Even a list of other caveats I didn’t considered would be helpful.

The conversation extension I wrote for my current project implements a hybrid approach - basically, in addition to the onstage characters, you create an offstage “conversation mastermind” character that the extension redirects any conversation actions to. You interact with the characters as usual, but whenever you use a conversation command, you’re actually talking to an invisible third party instead (who delivers the lines for both). It’s a surprisingly elegant solution - the extension has to smooth over a few quirks from the actor being offstage, but because everything is redirected, things like greetings and farewells are all handled automatically.

This is Inform 7, though, so I don’t know how easily the idea would translate to TADS. A few of the quirks you mention (conversation states, disambiguation) will probably require special logic regardless of what system you use.

Take a look at the CollectiveGroup class. If you make the two NPCs parts of a CollectiveGroup, there may be a way to redirect all of the conversational actions from individual NPCs to the CollectiveGroup. It will still be complicated, but that’s probably the place to start.

As a last resort, you could have one NPC be sort of semi-non-responsive. Here’s a bit of code (in adv3Lite, but adv3 would be similar) for one of a pair of NPCs in my WIP. This won’t help, I suspect, with the greeting protocols and InConversationState stuff – it’s a workaround.

+ DefaultAnyTopic, StopEventList [ 'Ray shrugs. <q>I got kind of a headache,</q> he says, and jerks a thumb at Chuck. <q>Talk to him.</q> ', '<q>I told you already,</q> Ray says. <q>I\'m not feelin\' real chatty this afternoon. You want to talk, talk to Chuck.</q> ', 'Ray has already suggested that you direct your conversation to Chuck. ' ] ;

Thank you all for the input. At first I’ve tried to make a proxy version of ConversationReadyState and InConversationState objects under both actors to redirect conversation to shared objects of the same class, but the mechanism was quite complex and I’ve quicky lost a track and didn’t produce anything useable.

But on the second try I’ve realized (quite suprisingly) that I can change the state of the second character during conversation with the first one to his InConversationState and nothing bad happens, the player can talk to both of them without any conflict. From there it was much easier, I’ve just nedded to automatically sync states of both characters, sync their boredom counter and make a proxy not for whole conversation state but only for topic database and here is what I came with:

[code]
#charset “utf-8”

#include <adv3.h>
#include <en_us.h>

versionInfo: GameID;

gameMain: GameMainDef
initialPlayerChar = me
;

room: Room ‘room’ ‘room’
"Room. "

vocabWords = 'room'

;

  • me: Actor;

/******************************************************************************/

/*

  • DisambigDeferrer by Michael J. Roberts from Return to Ditch Day
  • “Disambiguation deferrer” is a mix-in class that can be combined with
  • any ordinary Thing class to create an object that defers in
  • disambiguation situations to a given enumerated set of objects, or to
  • objects that match some rule. If we find any object from our
  • enumerated set, or any object that matches our rule, we’ll take
  • ourselves out of the picture for disambiguation purposes.
  • Deferral is useful in a lot of cases. Frequently, one object will
  • have a much stronger affinity for a given set of vocabulary words than
  • most other objects, but we’ll nonetheless want to make the other
  • objects match the vocabulary when the stronger object isn’t around.
  • The deferrer lets the weaker objects opt out when they see the
  • stronger object as a possible match.
    /
    class DisambigDeferrer: object
    /
    • A list of objects I defer to. We’ll remove ourselves from parsing
    • consideration if any of these objects are in scope.
      */
      disambigDeferTo =
/*
 *   Do we defer to the given object?  Returns true if we defer to the
 *   given object, nil if not.  By default, we defer if the object
 *   appears in our disambigDeferTo list.
 */
disambigDeferToObj(obj) { return disambigDeferTo.indexOf(obj) != nil; }

/*
 *   Filter the noun-phrase resolution list.  If we find any objects
 *   that we defer to in the match list, we'll defer by removing 'self'
 *   from the match list.
 */
filterResolveList(lst, action, whichObj, np, requiredNum)
{
    /* if there's an object we defer to, defer to it */
    if (lst.indexWhich({x: disambigDeferToObj(x.obj_)}) != nil)
    {
        /* find myself in the list */
        local i = lst.indexWhich({x: x.obj_ == self});

        /* remove myself from the list */
        lst = lst.removeElementAt(i);
    }

    /* return the updated list */
    return lst;
}

;

/*

  • ConversationStateSync is a mix-in class to be added to each of the actors
  • we need to keep in sync during conversation. When the class observes that
  • its actor changes from ConversationReadyState to InConversationState and
  • back, it will do the same change on the other actor so both will pretend
  • to be talking at the same time.
  • We do not pay an attention to conversation state changes other than the
  • ones specified lower. Naturally we need to add this class to both actors
  • and interlink them bidirectionaly together.
    /
    class ConversationStateSync: object
    /
    Who is the other actor? */
    otherActor = nil
/* My conversation states we observe. */
myConversationReadyState = nil
myInConversationState = nil

/* Other actor's conversation states to which we synchronise. */
otherConversationReadyState = nil
otherInConversationState = nil

/* Observe the state changes and do the same change on other actor. */
setCurState(state)
{
    inherited(state);

    if(curState == myConversationReadyState && otherActor.curState
        != otherConversationReadyState)
        otherActor.setCurState(otherConversationReadyState);

    if(curState == myInConversationState && otherActor.curState
        != otherInConversationState)
        otherActor.setCurState(otherInConversationState);
}

/*
 *   Also we must keep in sync the boredomCounter to prevent one actor to
 *   leave conversation.
 */
noteConvAction(other)
{
    inherited(other);
    if(curState == myInConversationState && otherActor.boredomCount > 0)
        otherActor.noteConvAction(other);
}

;

/*

  • We need a place to store common set of conversation topics for both
  • actors. CommonTopicDatabase is a class meant to be a root of conversation
  • topics. It inherits from ActorTopicDatabase the same way as Actor,
  • ActorState or ConvNode. We just override a method distinguishing to whom
  • we are actually talking (ProxyTopicDatabase notifies us about this).
    */
    class CommonTopicDatabase: ActorTopicDatabase
    fromActor = nil
getTopicOwner() { return fromActor; }

;

/*

  • This class is to be added to InConversationState of both actors and
  • manages proxying of topic searching from the actor’s topic database to
  • common topic database of both actors. Or more precisely it mixes both
  • topic databases together so you can have some topics common for both
  • actors while on the same time have some topics unique for each actor.
    /
    class ProxyTopicDatabase: object
    /
    CommonTopicDatabase object storing topics. */
    proxyTarget = nil
/*
 *   Get this state's suggested topic list.  ConversationReady states
 *   shouldn't normally have topic entries of their own, since a
 *   ConvversationReady state usually forwards conversation handling
 *   to its corresponding in-conversation state.  So, simply return
 *   the suggestion list from our in-conversation state object.
 */
stateSuggestedTopics()
{
    local lst = [];
    
    if(suggestedTopics != nil)
        lst += suggestedTopics;
    if(proxyTarget.suggestedTopics != nil)
        lst += proxyTarget.suggestedTopics;
    
    return lst.length() ? lst : nil;
}

/*
 *   This method is taken from the library, only modifications are
 *   topicList retrieval and filling proxyTarget.fromActor.
 *
 *   find the best response (a TopicEntry object) for the given topic
 *   (a ResolvedTopic object)
 */
findTopicResponse(fromActor, topic, convType, path)
{
    local topicList;
    local best, bestScore;

    proxyTarget.fromActor = self.location;

    /*
     *   Get the list of possible topics for this conversation type.
     *   The topic list is contained in one of our properties; exactly
     *   which property is determined by the conversation type.
     */
    topicList = (self.(convType.topicListProp) != nil ?
        self.(convType.topicListProp) : [])
        + (proxyTarget.(convType.topicListProp) != nil ?
        proxyTarget.(convType.topicListProp) : []);

    /* if the topic list is nil, we obviously won't find the topic */
    if (topicList.length() == 0)
        return nil;

    /* scan our topic list for the best match(es) */
    best = new Vector();
    bestScore = nil;
    foreach (local cur in topicList)
    {
        /* get this item's score */
        local score = cur.adjustScore(cur.matchTopic(fromActor, topic));

        /*
         *   If this item has a score at all, and the topic entry is
         *   marked as active, and it's best (or only) score so far,
         *   note it.  Ignore topics marked as not active, since
         *   they're in the topic database only provisionally.
         */
        if (score != nil
            && cur.checkIsActive()
            && (bestScore == nil || score >= bestScore))
        {
            /* clear the vector if we've found a better score */
            if (bestScore != nil && score > bestScore)
                best = new Vector();

            /* add this match to the list of ties for this score */
            best.append(cur);

            /* note the new best score */
            bestScore = score;
        }
    }

    /*
     *   If the best-match list is empty, we have no matches.  If
     *   there's just one match, we have a winner.  If we found more
     *   than one match tied for first place, we need to pick one
     *   winner.
     */
    if (best.length() == 0)
    {
        /* no matches at all */
        best = nil;
    }
    else if (best.length() == 1)
    {
        /* exactly one match - it's easy to pick the winner */
        best = best[1];
    }
    else
    {
        /*
         *   We have multiple topics tied for first place.  Run through
         *   the topic list and ask each topic to propose the winner.
         */
        local toks = topic.topicProd.getOrigTokenList().mapAll(
            {x: getTokVal(x)});
        local winner = nil;
        foreach (local t in best)
        {
            /* ask this topic what it thinks the winner should be */
            winner = t.breakTopicTie(best, topic, fromActor, toks);

            /* if the topic had an opinion, we can stop searching */
            if (winner != nil)
                break;
        }

        /*
         *   If no one had an opinion, run through the list again and
         *   try to pick by vocabulary match strength.  This is only
         *   possible when all of the topics are associated with
         *   simulation objects; if any topics have pattern matches, we
         *   can't use this method.
         */
        if (winner == nil)
        {
            local rWinner = nil;
            foreach (local t in best)
            {
                /* get this topic's match object(s) */
                local m = t.matchObj;
                if (m == nil)
                {
                    /*
                     *   there's no match object - it's not comparable
                     *   to others in terms of match strength, so we
                     *   can't use this method to break the tie
                     */
                    winner = nil;
                    break;
                }

                /*
                 *   If it's a list, search for an element with a
                 *   ResolveInfo entry in the topic match, using the
                 *   strongest match if we find more than one.
                 *   Otherwise, just use the strength of this match.
                 */
                local ri;
                if (m.ofKind(Collection))
                {
                    /* search for a ResolveInfo object */
                    foreach (local mm in m)
                    {
                        /* get this topic */
                        local riCur = topic.getResolveInfo(mm);

                        /* if this is the best match so far, keep it */
                        if (compareVocabMatch(riCur, ri) > 0)
                            ri = riCur;
                    }
                }
                else
                {
                    /* get the ResolveInfo object */
                    ri = topic.getResolveInfo(m);
                }

                /*
                 *   if we didn't find a match, we can't use this
                 *   method to break the tie
                 */
                if (ri == nil)
                {
                    winner = nil;
                    break;
                }

                /*
                 *   if this is the best match so far, elect it as the
                 *   tentative winner
                 */
                if (compareVocabMatch(ri, rWinner) > 0)
                {
                    rWinner = ri;
                    winner = t;
                }
            }
        }

        /*
         *   if there's a tie-breaking winner, use it; otherwise just
         *   arbitrarily pick the first item in the list of ties
         */
        best = (winner != nil ? winner : best[1]);
    }

    /*
     *   If there's a hierarchical search path, AND this topic entry
     *   defines a deferToEntry() method, look for matches in the
     *   inferior databases on the path and check to see if we want to
     *   defer to one of them.
     */
    if (best != nil && path != nil && best.propDefined(&deferToEntry))
    {
        /* look for a match in each inferior database */
        for (local i = 1, local len = path.length() ; i <= len ; ++i)
        {
            local inf;

            /*
             *   Look up an entry in this inferior database.  Pass in
             *   the remainder of the path, so that the inferior
             *   database can consider further deferral to its own
             *   inferior databases.
             */
            inf = path[i].findTopicResponse(fromActor, topic, convType,
                                            path.sublist(i + 1));

            /*
             *   if we found an entry in this inferior database, and
             *   our entry defers to the inferior entry, then ignore
             *   the match in our own database
             */
            if (inf != nil && best.deferToEntry(inf))
                return nil;
        }
    }

    /* return the best matching response object, if any */
    return best;
}

;

/*****************************************************************************/
/

  • Example usage mainly based on some conversation examples from Eric Eve’s
  • book Learning TADS 3.
    */
    alice: ConversationStateSync, Actor ‘alice’ ‘Alice’ @room
/* Sync with other actor. */
otherActor = bob
myConversationReadyState = aliceLooking
myInConversationState = aliceTalking
otherConversationReadyState = bobLooking
otherInConversationState = bobTalking

;

  • aliceTalking: ProxyTopicDatabase, InConversationState
    specialDesc = "Alice is standing by the shop window, waiting for you to
    speak. "
    stateDesc = "She’s waiting for you to speak. "

    /* Where are the commont topics? */
    proxyTarget = commonTopics
    ;

++ aliceLooking: ConversationReadyState
isInitState = true
commonDesc = " standing in the street, peering into a shop window. "
specialDesc = “Alice is <>”
stateDesc = “She’s <>”
;

+++ HelloTopic, StopEventList
[
'Hello, there! you say.\b
Hi! Alice replies, turning to you with a smile. ',
'Hello, again, you greet her.\b
Yes? she replies, turning back to you. ’
]
;

+++ ByeTopic
"Well, cheerio then! you say.\b
'Bye for now, Alice replies, turning back to the shop window. "
;

+++ BoredByeTopic
"Alice gives up waiting for you to speak and turns back to the shop window. "
;

+++ LeaveByeTopic
"Alice watches you walk away, then turns back to the shop window. ";
;

++ AskTopic @alice
"How are you today? you ask.\b
Fine, just fine, she assures you. "
;

/******************************************************************************/

bob: DisambigDeferrer, ConversationStateSync, Actor ‘bob’ ‘Bob’ @room

/* Sync with other actor. */
otherActor = alice
myConversationReadyState = bobLooking
myInConversationState = bobTalking
otherConversationReadyState = aliceLooking
otherInConversationState = aliceTalking

/*
 *   We don't want push the player into deciding to which of the NPC he
 *   wants to talk so we made on of the NPCs to step back while in
 *   disambiguation situation.
 */
disambigDeferToObj(obj) { return obj == alice && me.isIn(room); }

;

  • bobTalking: ProxyTopicDatabase, InConversationState
    specialDesc = "Bob is standing by the shop window, waiting for you to
    speak. "
    stateDesc = "He’s waiting for you to speak. "

    /* Where are the commont topics? */
    proxyTarget = commonTopics
    ;

++ bobLooking: ConversationReadyState
isInitState = true
commonDesc = " standing in the street, peering into a shop window. "
specialDesc = “Bob is <>”
stateDesc = “He’s <>”
;

+++ HelloTopic, StopEventList
[
'Hello, there! you say.\b
Hi! Bob replies, turning to you with a smile. ',
'Hello, again, you greet him.\b
Yes? he replies, turning back to you. ’
]
;

+++ ByeTopic
"Well, cheerio then! you say.\b
'Bye for now, Bob replies, turning back to the shop window. "
;

+++ BoredByeTopic
"Bob gives up waiting for you to speak and turns back to the shop window. "
;

+++ LeaveByeTopic
"Bob watches you walk away, then turns back to the shop window. ";
;

++ AskTopic @bob
"How are you today? you ask.\b
Fine, just fine, he assures you. "
;

/******************************************************************************/

  • commonTopics: CommonTopicDatabase;

++ AskTopic @room
"What do you thing about this room? you ask.\b
Pretty bare, but fine, he assures you. "
;

++ DefaultAnyTopic
/* Sometimes we need to know to whom we are talking to adjust common messages. */
"We can discuss that when I’m not so busy, <<location.fromActor.name>> suggests. "
;[/code]

And although some minor glitches, such as bye message seems to come only from Bob, it seems to work overall, although still only basically tested:

[code]
Room
Room.

Alice is standing in the street, peering into a shop window.

Bob is standing in the street, peering into a shop window.

x bob
He’s standing in the street, peering into a shop window.

x alice
She’s standing in the street, peering into a shop window.

talk to alice
“Hello, there!” you say.

“Hi!” Alice replies, turning to you with a smile.

x bob
He’s waiting for you to speak.

a alice
“How are you today?” you ask.

“Fine, just fine,” she assures you.

ask bob about room
“What do you thing about this room?” you ask.

“Pretty bare, but fine,” he assures you.[/code]