Pattern for non-action report manager/combiner?

I’ve got a bunch of NPC fidgets and other messages generated by sources other than the player’s action. Is there a convenient pattern for handling this a la combineReports.t?

For reports generated by the player action that turn you can always add a report manager via gAction.callAfterActionMain() (or by tweaking afterActionMain() on specific actions, which is what combineReports.t does). But for NPC fidgets, output from daemons, and so on this doesn’t work, as they’re generally generated too late in the turn order.

Sample thing:

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

startRoom:      Room 'Void'
        "This is a featureless void. "
        roomDaemon() {
                if(!(libGlobal.totalTurns % 3))
                        extraReport('The room daemon ruminates. ');
        }
;
+me: Person;
+alice: Person 'alice' 'Alice'
        "She looks like the first person you'd turn to in a problem."
        isHer = true
        isProperName = true
;
++AgendaItem
        initiallyActive = true
        invokeItem() {
                extraReport('Alice alliterates. ');
        }
;
+bob: Person 'bob' 'Bob'
        "He looks like a Robert, only shorter. "
        isHim = true
        isProperName = true
;
++AgendaItem
        initiallyActive = true
        invokeItem() {
                if(!(libGlobal.totalTurns % 2))
                        extraReport('Bob bloviates. ');
        }
;

versionInfo: GameID;
gameMain: GameMainDef initialPlayerChar = me;

This has three fidgets/barks generated at different intervals (Alice alliterates every turn, Bob bloviates every other turn, and the room daemon ruminates every third turn). What I want to do is implement some mechanism by which these reports are combined on turns in which more than one is generated.

My first attempt was to create a report manager in the rough form of actionReportManager from combineReports.t, and call it in e.g. startRoom.roomDaemon(). Something like:

fidgetReportManager: object
        afterActionMain() {
                // Report managing goes here
        }
        activate() {
                gAction.callAfterActionMain(self);
        }
;

startRoom:      Room 'Void'
        "This is a featureless void. "
        roomDaemon() {
                // THIS DOES NOT WORK!
                fidgetReportManager.activate();
                if(!(libGlobal.totalTurns % 3))
                        extraReport('The room daemon ruminates. ');
        }
;

But roomDaemon() isn’t called until after any afterActionMain()s have been called.

Is an output filter the closest thing to a “global” report manager provided natively by adv3? Or am I missing something obvious?

2 Likes

CaptureFilter was my first thought. You could potentially capture each non-PC transcript to a manager’s vector of strings, and as the final step in the execution cycle of a given turn count, sort/combine the vector similar to other report managers.
Without using capture, I can’t imagine how you’d do it that doesn’t involve rewriting the whole transcript/action cycle mechanisms…

1 Like

Curious if you have a motivation for using extraReport within the AgendaItems?

I was originally adding all the fidgets as reports, because twiddling the transcript is pretty straightforward that way. But since it turns out that using a “normal” report combiner/manager doesn’t work because of the timing issues, there’s no particular reason why they need to be reports.

What I’m thinking of now is just adding a report manager sort of thing to some code I put together some time ago to handle fidget-like notifications from sources outside the player’s current sense context. That was specifically to twiddle the ordering of that kind of message, but it would be easy enough to add arbitrary filtering/sorting/whatever.

1 Like

Are you using capture for that, or how are you doing it?

The way I implemented the previous stuff was just a simple message queue. Well, a queue for the messages with a priority specified and a FIFO for message with no priority. And then a simple daemon that flushes the queue (and FIFO) every turn. That was following discussion in a thread from last year.

It looks like I never made the repo public, but the code is pretty short:

#define absentOutput absentOutputDaemon.output

modify Actor
        // Simple fidget, only ever emits anything if the player is
        // in the given sense context.
        fidget(v0, sense?) {
                callWithSenseContext(self, (sense ? sense : sight),
                        {: "<<v0>> " });
        }

        // Generalized fidget method for actors.
        // Arguments are the message to show the player if the fidgeting
        // actor is in the same sense context as them, the message to show
        // if they AREN'T in the same context, and the sense to use
        // to evaluate the context (using sight if none is given).
        fidgetNearAndFar(v0, v1, sense?) {
                if(!sense)
                        sense = sight;

                if(gPlayerChar.senseObj(sense, self).trans != opaque)
                        callWithSenseContext(self, sense, {: "<<v0>> " });
                else
                        callWithSenseContext(nil, nil, {: "<<v1>> "});
        }
;


// Class for messages with specific priorities.
class AbsentOutputMsg: object
        msg0 = nil              // Text literal of message, in context
        msg1 = nil              // Text, out of context
        actor = nil             // Optional source of the message
        priority = nil          // Message priority
        sense = nil             // Sense to use for context

        // Allow both properties to be set by the constructor
        construct(v0, v1, a?, s?, pri?) {
                msg0 = v0;
                msg1 = v1;
                actor = ((a != nil) ? a : nil);
                sense = ((s != nil) ? s : nil);
                priority = ((pri != nil) ? pri : 0);
        }
;

// Simple output queue daemon for handling messages with explicit priorities.
// We also implement a non-sorted FIFO for messages without priorities.  Done
// mostly to make the semantics easier (can log messages the same way whether
// or not they need to be sorted).
absentOutputDaemon: object
        _daemon = nil           // T3 Daemon that gets called by the scheduler
        _queue = nil            // Queue for sortable messages
        _fifo = nil             // FIFO for messages without priorities

        // Initialize the daemon if it isn't already running.
        initDaemon() {
                if(_daemon) return;
                _daemon = new Daemon(self, &runDaemon, 1);
        }

        // Main entry point for external callers.  Args are the message itself
        // (a text literal) and the priority, if any, of the message.
        output(v0, v1, a?, s?, pri?) {
                local m;

                if(!_daemon)
                        initDaemon();

                // Sanity check our environment
                if(_queue == nil)
                        _queue = new Vector();
                if(_fifo == nil)
                        _fifo = new Vector();
                
                // If we haven't been given a priority, log to the FIFO,
                // otherwise log to the queue.
                m = new AbsentOutputMsg(v0, v1, a, s, pri);
                if(pri == nil)
                        _fifo.append(m);
                else
                        _queue.append(m);
        }

        // Output a single message.
        // If an actor is given, then we display the message as a fidget
        // by that actor.  If we don't have an actor, then we just output
        // the message.
        _output(v) {
                if((v == nil) || !v.ofKind(AbsentOutputMsg)) return;
                if(v.actor)
                        v.actor.fidgetNearAndFar(v.msg0, v.msg1, v.sense);
                else
                        "<<v.msg0>> ";
        }

        // Called by T3/adv3 every turn.
        // This is where we flush the FIFO and sort and flush the message
        // queue.
        runDaemon() {
                // The messages of the FIFO are output in the order they were
                // added.
                if(_fifo != nil) {
                        _fifo.forEach(function(o) { _output(o); });
                }

                // Now we sort the queue by the numerical priority, and then
                // go through the sorted list in order and output everything.
                if(_queue != nil)
                        _queue.sort(true, { a, b: a.priority - b.priority })
                                .forEach(function(o) { _output(o); });

                // Reset the FIFO and message queue.
                _queue.setLength(0);
                _fifo.setLength(0);
        }
;

The module name is “absentOutput” because it was designed to handle stuff from outside the player’s current sense context. The module lets you do things like:

                 absentOutput('Bob yelps in pain.', 'You hear
                         Bob yelp in pain.', bob, sound, 75);
                 absentOutput('Alice yells <q>Zorch!</a> as flames
                         billow from her fingertips.', 'Off in the
                         distance you hear Alice yelling.', alice,
                         sound, 100);

Then if the player is in the same room (and/or sound sense context) as Alice and Bob, this’ll output:

Alice yells "Zorch!" as flams billow from her fingertips.  Bob yelps in pain.

And if the player is in a different room:

Off in the distance you hear Alice yelling.  You hear Bob yelp in pain.

The “gimmick” or whatever being that we can add messages to the queue in arbitrary order, and the priorities determine the order that they’re output in.

What I’m talking about doing now is re-write this to be more general, so that you can attach arbitrary filters/report managers/whatever you want to call them, kinda like things like combineReports.t does for action reports.

2 Likes

So basically, on an Actor’s turn you never print any text, you just send info to the output daemon?

1 Like

Small note: here

        construct(v0, v1, a?, s?, pri?) {
                actor = ((a != nil) ? a : nil);
                sense = ((s != nil) ? s : nil);

you should be able to do

        construct(v0, v1, a?, s?, pri?) {
                actor = a;
                sense = s;

just because for any optional argument that is not supplied by the caller, the optional argument will be given the value nil within the function body…
In the same manner you can also do

        construct(v0, v1, a?, s?, pri=0) {
               priority = pri;
1 Like

Yeah, instead of outputting directly all of the NPC fidgets (and whatever else) are added to a message queue which is flushed once per turn. It’s more or less the same way action reports work natively in adv3, only it’s not tied to action evaluation.

As to why the constructor is set up that way, it’s a couple of things. I don’t put the defaults in argument list for methods in abstract classes because they’re likely to get overridden by subclasses/instances. And I usually do fairly chatty setting of defaults, particularly in constructors, mostly for clarity: in a couple months I’m going to be looking at this code trying to figure out what it does and I’m kinda slow and I’m easily distracted, so I prefer to not have to do any mental arithmetic or whatever you want to call it about what’s getting set where. If that makes sense.

Like the way I think I’m going to redo the code I quoted above is something like:

// Class for messages with specific priorities.
class MsgQueueMsg: object
        msg = nil               // Text literal of message
        priority = nil          // Message priority

        // Allow both properties to be set by the constructor
        construct(v, pri?) {
                msg = v;
                priority = ((pri != nil) ? pri : 0);
        }

        // Just print the message.
        output() { "<<msg>> "; }
;

// Class for messages that are only displayed when the source is
// in the player's sense context.
class MsgQueueMsgSense: MsgQueueMsg
        src = nil               // Source of the message
        sense = nil             // Sense to use to check context

        construct(v, pri?, a?, s?) {
                inherited(v, pri);
                src = ((a != nil) ? a : nil);
                sense = ((s != nil) ? s : nil);
        }

        output() {
                local s;

                // If we don't have a source, bail.
                if(src == nil)
                        return;

                // If we have a sense defined, used it.  Otherwise use
                // sight.
                s = (sense ? sense : sight);

                // Only display the message if the player is in the same
                // sense context as the message source.
                if(gPlayerChar.senseObj(s, src).trans != opaque)
                        callWithSenseContext(src, sense, {: "<<msg>> " });
        }
;

// Class for messages that display one message when the source is in the
// player's sense context and a different message when the source is not
// in the player's sense context.
class MsgQueueMsgSenseDual: MsgQueueMsgSense
        msgOutOfContext = nil   // Message used when out of context

        construct(v0, v1?, pri?, a?, s?) {
                inherited(v0, pri, a, s);
                msgOutOfContext = ((v1 != nil) ? v1 : nil);
        }

        output() {
                local s;

                // We're the same as MsgQueueMsgSense.output() until the end.
                if(src == nil)
                        return;

                s = (sense ? sense : sight);

                if(gPlayerChar.senseObj(s, src).trans != opaque) {
                        callWithSenseContext(src, sense, {: "<<msg>> " });
                        return;
                }

                // If we've reached this point, the message source isn't
                // in the same sense context as the player.  So if we don't
                // have an out-of-context message, bail.
                if(msgOutOfContext == nil)
                        return;

                // Just output the message.
                callWithSenseContext(nil, nil, {: "<<msgOutOfContext>> " });
        }
;

…and so I’ve got multiple message classes and they each have slightly different constructor semantics and it’s actually pretty straightforward but I guarantee I could confuse myself about what’s going on if I didn’t use verbose default-setting like that.

With all nils it’s not that bad, but once you start mixing in other values…

2 Likes

Bookmarked for potential useful reference. Later in my development I must tackle a really complex NPC handling (NPC reacting to the other NPC reaction: for example, taking jbg’s absentOutput example above, Alice should yell for help when Bob yelp in pain instead of casting Zorch; it’s a level of NPC detail where even simple party bantering needs careful combination management, the worst case being an infinite loop caused by unlucky random selection of party banter/retort line…

Best regards from Italy,
dott. Piergiorgio.

2 Likes

Yeah, handling rulebook-ish/scene-ish situations where individual NPC fidgets sometimes need to be munged together into a single, situation-specific fidget is basically my design problem here.

I think the new module’s going to be called msgQueue. absentOutput was named that way because I already had a bunch of “absent” modules loosely related to each other because they all involved scope gymnastics, but the new code is more general than that.

I was kinda leaning toward using a “report” nomenclature for things because this stuff is all kinda/sorta like the adv3 native CommandReport stuff, but I think that might just be confusing (and possibly result in name collisions).

I’m also not sure how much instrumentation of the filtering, sorting, and combining logic should just be left to the individual games (versus being provided by the module). Right now I just let external callers register filters to the message queue daemon, and then the daemon pings all of the active filters every turn, and it’s up to the filter to just look at the current state of the message queue and decide what it wants to do.

2 Likes

Oh, the repo is here, but it’s still very much in active development, so I wouldn’t build anything that depends on it yet. I’m still adding/removing/renaming stuff on a regular basis.

2 Likes

Just kinda thinking out loud here, but I just changed the way filtering works internally: instead of “actually” removing any messages from the queue, filters can mark messages as inactive, causing them to not be output.

My thinking on this is that changing the queue itself is comparatively expensive and requires filters to be “smarter” about how they behave. And since the queue gets flushed every turn anyway, there’s no concern about accidentally letting the queue grow too large or anything like that.

It also lets filters re-activate messages that had been “removed” by other filters, but that might be a misfeature. It’s not something that’ll just happen by accident in any case.

Anyway, just a little implementation detail that, like I said, I’m more or less just thinking out loud about.

2 Likes

I’ve done some more hammering away at this.

Simple message filtering is done-ish. In the sense that it works and I don’t know what else I’d want out of the basic underlying mechanism, so I don’t think it’s likely to see much additional churn.

A very simple filter looks something like (all of the examples below can be found in demo/src/combineTest.t in the repo):

// The filter itself.  Needs to be activated by using
//
//     msgQueueDaemon.addFilter(bobFilter);
//
// somewhere.
bobFilter: MsgQueueFilter
        filter() {
                filterMessages(function(obj) {
                        if(obj.src != bob) return;
                        obj.deactivate();
                });
        }
;

This just filters out all of the message originating from bob. The filter() method is called every turn for all registered filters immediately before outputting messages.

filterMessages() is a macro. It takes a callback function as its only argument, going through each message in the queue and calling the callback with the message as an argument. In this case all the callback function does is check to see if bob is the source of the message and deactivating it if so.

A more elaborate filtering method (and which might get tweaked or changed, not sure) is:

aliceCarolFilter: MsgQueueFilter
        filter() {
                summarizeMessages(function(obj) {
                        return(obj.ofKind(AliceCarolFidget)
                                && (obj.combine == true));
                }, function(v, ctx) {
                        local l;

                        l = new Vector(v.length);
                        v.forEach(function(o) {
                                l.append(o.src.theName);
                        });
                        if(ctx)
                                fidget('<<stringLister.makeSimpleList(l)>>
                                        fidget in context.');
                        else
                                fidget('<<stringLister.makeSimpleList(l)>>
                                        fidget out of context.');
                });
        }
;

Like the other filter, the filter() method will be called every turn (assuming the filter is registered first via msgQueueDaemon.addFilter(aliceCarolFilter) somewhere).

In this case the filter will attempt to combine messages using summarizeMessages(). summarizeMessages() is another macro, this one taking two arguments:

  • A callback function taking one argument. It will be called with each message in the queue as the argument, and every message for which this callback returns boolean true will be added to a results list.
  • A report function taking two arguments. If the results list generated above contains at least two elements the report function will be called with the results list as the first argument. The second argument will be a flag indicating if the messages in the results list are all in the same sense context as the current player (boolean true), if they’re all not in the same sense context as the current player (nil), or if none of the message are sense context dependent (-1).

I haven’t refactored all the code in my WIP that wants to use this yet, so I don’t know how many tweaks and modifications I’m going to make. But I think this is most of the core functionality that I care about.

2 Likes

Just added one minor thing to make the first example even simpler: a class for filters that only ever check individual messages. Using it, you can do the first example like:

bobFilter: MsgQueueFilterSimple
        simpleFilter(obj) {
                if(obj.src == bob) obj.deactivate();
        }
;

This is pure semantic sugar; it produces the same results as the first example in the post above (which still works). It just requires slightly less typing.

2 Likes