Dynamic, knowledge-based text

I’ve found that I frequently have situations where I want to change how things are referred to based on game state/player knowledge. This isn’t a particularly big deal to handle as a bunch of individual one-offs, but it was coming up enough that I decided to throw together a little library/module to handle it.

The basic idea is that you might have a room (or whatever) that’s initially identified as “A Dark Cave” or something when the player first sees it, but then later they discover it’s actually “Entrance To The Secret Hideout” or something like that.

Here I’m controlling the behavior using a simple gRevealed() check, but it would be easy enough to extend this to more complex checks. Anyway, here’s a demo with all the library code inlined:

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

// Enum containing all our dynamic word types.
enum dWord, dTitle;

// Preinit object that we use as a container for methods.
dynamicWords: PreinitObject
        // A lookup table indexed by key (the one used for gRevealed())
        // containing all our DynamicWord instances.
        _table = nil

        // Setup our lookup table of words
        execute() {
                _table = new LookupTable();
                forEachInstance(DynamicWord, function(o) {
                        _table[o.id] = o;
                });
        }

        // Return the current word matching the given ID and, optionally,
        // of the type specified in the flags.
        getWord(id, flags?) {
                local o;

                o = _table[id];
                if(o == nil)
                        return(nil);

                // We don't do anything fancy here, yet.
                switch(flags) {
                        case dTitle:
                                return(o.getWordAsTitle());
                        default:
                                return(o.getWord());
                }
        }
;

// Class to hold all the stuff for a single dynamic word.
class DynamicWord: object
        id = nil                // key to use for gRevealed()
        word = nil              // "revealed" basic form of the word
        wordAsTitle = nil       // "revealed" word formatted as a title
        initWord = nil          // "unrevealed" basic form of the word
        initWordAsTitle = nil   // "unrevealed" word formatted as a title

        construct(n, w0?, w1?, l0?, l1?) {
                id = n;
                initWord = (w0 ? w0 : nil);
                word = (w1 ? w1 : nil);
                initWordAsTitle = (l0 ? l0 : nil);
                wordAsTitle = (l1 ? l1 : nil);
        }

        // Return the basic form of the word.  Every instance has to have
        // something defined for word and initWord.
        getWord() { return(gRevealed(id) ? word : initWord); }

        // Return the word formatted for use as a title, e.g. in a room name.
        getWordAsTitle() {
                return(gRevealed(id) ? wordAsTitle : initWordAsTitle);
        }
;

// Template for the DynamicWord class.
DynamicWord template 'id' 'initWord'? 'word'?;

// Convenience macro for defining a DynamicWord instance.
// Creates global functions to make referring to instance simpler:
// if the ID is "foo", fooWord() will return the basic form of the word
// and fooWordAsTitle() will return the word formatted as a title (to be
// used in for example a room name).
#define DefineDynamicWord(id, name, init, reveal...) \
        id##Word() { return(id##DynamicWord.getWord()); } \
        id##WordAsTitle() { return(id##DynamicWord.getWordAsTitle()); } \
        id##DynamicWord: DynamicWord name init reveal

// Convenience macros for accessing the dynamic words by their keys.
#define dWord(key, args...) dynamicWords.getWord(key, args)
#define dWordAsTitle(key, args...) dynamicWords.getWord(key, title)

// Creates an object called voidDynamicWord and global functions
// voidWord() and voidWordAsTitle().  voidWord() will return
// 'unknown void' if gRevealed('voidFlag') returns nil and 'formless void'
// if it returns true.  voidWordAsTitle() will return 'Void of Some Kind'
// and 'Formless Void', also testing gRevealed('voidFlag').
DefineDynamicWord(void, 'voidFlag', 'unknown void', 'formless void')
        initWordAsTitle = 'Void of Some Kind'
        wordAsTitle = 'Formless Void'
;

// The room name and description use the dynamic word we defined above.
startRoom:      Room '<<voidWordAsTitle()>>'
        "This is a <<voidWord()>>.  There's a plaque on the wall. "
;
// Examining the sign sets voidFlag.
+Fixture 'plaque/sign' 'plaque'
        "The plaque identifies this room as the Formless Void.
        <.reveal voidFlag> "
;
+me:    Person;

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
;

The interesting bits (from a game implementor’s standpoint) are that you can use something like:

DefineDynamicWord(void, 'voidFlag', 'unknown void', 'formless void');

Where the args in this specific example are:

  • void is used to create a couple convenience methods of the form [arg] + Word and [arg] + WordAsTitle, in this case voidWord() and voidWordAsTitle()
  • voidFlag is the single-quoted string to use for the gRevealed() check
  • unknown void is the single-quoted string to use for the “unrevealed” word
  • formless void is the single-quoted string to use for the “revealed” word

…and then anywhere in the game code voidWord() can be used and will return the appropriate version based on whether or not voidFlag has been revealed. In addition, the initWordAsTitle and wordAsTitle properties can be declared and then accessed via voidWordAsTitle(). This is intended to hold the word/phrase formatted as a “title”, as for example used in a room title. E.g.:

DefineDynamicWord(void, 'voidFlag', 'unknown void', 'formless void')
        initWordAsTitle = 'Void of Some Kind'
        wordAsTitle = 'Formless Void'
;
startRoom:      Room '<<voidWordAsTitle()>>'
        "This is a <<voidWord()>>.  There's a plaque on the wall. "
;
+Fixture 'plaque/sign' 'plaque'
        "The plaque identifies this room as the Formless Void.
        <.reveal voidFlag> "
;

Gives us a transcript like:

Void of Some Kind
This is a unknown void.  There's a plaque on the wall.

>x sign
The plaque identifies this room as the Formless Void.

>l
Formless Void
This is a formless void.  There's a plaque on the wall.

In addition to using the dynamically created functions (in this case voidWord() and voidWordAsTitle()) you can do the same thing via dWord('voidFlag') and dWord('voidFlag', dTitle), respectively. That is, access the current value(s) for the DynamicWord instance by key instead of by their “personal” function names.

This is extremely simple stuff, but it’s a thing that has come up a lot in implementing a mystery-ish game. So I figured I’d throw it out there in case it’s useful for anyone else.

The code is available in the form of a T3 module is available from a github repo and will almost certainly be updated as I try to hammer my WIP into shape.

5 Likes

I suggest adding it to the TADS cookbook.

4 Likes

Maybe once the code’s more stable.

Just added a derived class, StateWord for handling the same sorts of substitutions via more elaborate checks. Example:

DefineStateWord(fsm, 'fsmID', 'mysterious room', [
                'room with a foo' -> &checkFoo,
                'room with a bar' -> 'rBar',
                'room with a foo and bar' -> [ 'rFoo', 'rBar' ]
        ])
        checkFoo() { return(gRevealed('rFoo')); }
;

…which will create a fsmWord() method that will return “room with a foo” if the checkFoo() method returns true, “room with a bar” if gRevealed('rBar') returns true, “room with a foo and bar” if both return true, and “mysterious room” if none of the above conditions apply.

The version in the repo also now as a utility method to convert strings to “title case” (first letter of every word capitalized) so you don’t have to explicitly define DynamicWord.wordAsTitle if you just want DynamicWord.word capitalized. I also broke out an explicit DynamicWord.check() method (instead of embedding it in other methods) to allow stuff other than gRevealed() checks to be used in DynamicWord.

I’m also going to want to at least add some additional utility methods to allow Thing instances to subscribe for notification from DynamicWords, so they can update their vocabulary if necessary.

Creeping up on my implementation targets here. Added and modified various functionality. Current version illustrated by a simple mystery “game”, with the main moving parts (not counting the module itself, which is in the repo):

DefineStateWord(cave, 'cave', 'mysterious cave', [
                'Bob\'s secret hideout' -> &checkBob,   
                'the killer\'s hidden lair' -> &checkKiller,
                'the killer\'s (Bob\'s) secret lair'
                        -> [ &checkBob, &checkKiller ]
        ])
        checkBob() {
                if(gRevealed('bobFlag')) {
                        isProperName = true;
                        return(true);
                }
                return(nil);
        }
        checkKiller() {
                if(gRevealed('killerFlag')) {
                        isProperName = true;
                        return(true);
                }
                return(nil);
        }
;
startRoom:      Room 'Entrance to {a caveTitle/him}'
        "This is the entrance to {a cave/him}.  There's large steel door
        on the north wall with a sign on it. "
;
+Fixture 'sign' 'sign'
        "The sign says, <q>Bob's Secret Hideout</q>.
        <.reveal bobFlag> "
;
+me: Person;
+knife: Thing 'bloody butcher knife' 'butcher knife'
        "A butcher knife.  The blood on the blade indicates it is
        the murder weapon.
        <.reveal killerFlag> "
;

This gives us one room, initially identified as “Entrance to a Mysterious Cave”. There are two pieces of “evidence”: a sign and a knife. Looking at the sign tells you that the cave is Bob’s secret hideout. Looking at the knife tells you that the killer was here. The name of the location updates to reflect whether the player has seen one, the other, or both of the pieces of evidence.

A transcript:

Entrance to a Mysterious Cave
This is the entrance to a mysterious cave.  There's large steel door on the
north wall with a sign on it.

You see a butcher knife here.

>x sign
The sign says, "Bob's Secret Hideout".

>l
Entrance to Bob's Secret Hideout
This is the entrance to Bob's secret hideout.  There's large steel door on the
north wall with a sign on it.

You see a butcher knife here.

>x knife
A butcher knife.  The blood on the blade indicates it is the murder weapon.

>l
Entrance to The Killer's (Bob's) Secret Lair
This is the entrance to the killer's (Bob's) secret lair.  There's large steel
door on the north wall with a sign on it.                                      

You see a butcher knife here.

>restart
Do you really want to start over? (Y is affirmative) > y

Entrance to a Mysterious Cave
This is the entrance to a mysterious cave.  There's large steel door on the
north wall with a sign on it.

You see a butcher knife here.

>x knife
A butcher knife.  The blood on the blade indicates it is the murder weapon.

>l
Entrance to The Killer's Hidden Lair
This is the entrance to the killer's hidden lair.  There's large steel door on
the north wall with a sign on it.                                              

You see a butcher knife here.

>

Beyond the basic functionality, message parameter substitutions are now automagically created for DynamicWord instances, using the instance’s ID as the substitution parameter. So you can now do things like "This is the entrance to {a cave/him}. " and get "This is the entrance to a mysterious cave. " or "This is the entrance to Bob’s secret hideout. " as currently appropriate, and T3’s builtin logic takes care of handling the articles and so on, instead of you having to bake them into the dynamic text.

Next bit of functionality is, hopefully, going to be dynamic vocabulary for in-game objects using defined DynamicWord instances.

3 Likes

Continuing to think out loud here: I’ve forked the DynamicWord module into DynamicThing (separate repo, same user on githib) and changed the basic usage. Instead of defining a state table, DynamicThing implements the “knowledge states” or whatever you want to call them kinda-sorta like ThingState or ActorState in adv3: you define the “main” DynamicThing, which basically a bucket for the “abstract concept” the knowledge represents, and then throw DynamicThingStates onto it to provide the vocabulary. An example, functionally identical to the one in my last post:

abstractCave: DynamicThing 'cave';
+DynamicThingStateDefault 'mysterious cave' 'mysterious cave';
+fooState: DynamicThingState
        '(secret) hideout' 'Bob\'s secret hideout' +1 'bobFlag'
        isProperName = true
;
+barState: DynamicThingState
        '(hidden) lair' 'the killer\'s hidden lair' +1 'killerFlag'
        isProperName = true;
;
+DynamicThingState '(secret) lair' 'the secret lair of Bob, the killer' +2
        isProperName = true
        dtsCheck() {
                return(fooState.dtsCheck() && barState.dtsCheck());
        }
;

The template for DynamicThing contains just a single-quoted string, which is what you use for message parameter substitutions, in this case ‘cave’, giving you “This is the entrance to {a cave/him}.”, for example.

The template for DynamicThingState is (in order): single-quoted vocabulary (specified as in a normal Thing declaration); single-quoted name (again exactly as in a normal Thing declaration); optionally a plus sign and a number used in ordering the states (the highest-numbered state that tests as active is the state used); and optionally a single-quoted string to use with gRevealed(). So…

DynamicThingState '(secret) hideout' 'Bob\'s secret hideout' +1 'bobFlag'

…creates a state with ‘secret’ as an adjective and ‘hideout’ as a noun for vocabulary, and “Bob’s secret hideout” as the name. It’s order is 1 (so it’s evaluated after anything numbered 0 and before anything numbered 2 or greater), and its check method evaluates as true if gRevealed('bobFlag') returns true. The other thing you can do is…

DynamicThingState '(secret) lair' 'the secret lair of Bob, the killer' +2
        isProperName = true
        dtsCheck() {
                return(fooState.dtsCheck() && barState.dtsCheck());
        }
;

…which doesn’t use its own gRevealed() check, it defines its own check method, which returns true of the check method of the two named states returns true.

Again, this works functionally identically to the prior version I posted above…this just uses a format somewhat more like other things in “stock” adv3. I think I also prefer it for making the state checks a little easier to pick out when you’re scanning the code.

Also, done this way you could (if you wanted to) just use it more or less as-is as a mixin on an existing Thing to get this behavior on it. But that’s not what I’m after, what I want to do is to use this on groups of objects, rooms, and so on, both in descriptions and in vocabulary.

3 Likes

Thanks, jbg: exactly what I need often in my major WIP, where indeed the main element is figuring understanding the “five W’s” of the very strange environment…

Best regards from Italy,
dott. Piergiorgio.

Still fiddling with this but can’t quite get what I want working (in a clean, modular way).

The main problem is that TADS3/adv3 doesn’t really have a strong, programmatic way of working with prepositional phrases or applying them to existing object vocabularies. So I can change a Thing from being a “mysterious cave” to being a “hidden lair” in a way that works as expected, but a key you want to describe as belonging to or being a part of that Thing…doesn’t work as expected.

It’s easy enough to slap individual vocabulary bits (e.g. all the adjectives) from the cave onto the key, but that makes a “mysterious key” or a “hidden key”, not a “mysterious cave key” or “hidden lair key”…and kludging them (by just adding the cave’s nouns to the key’s list of adjectives) doesn’t get you an object that can be understood as “the key to the mysterious cave” or “the key to the hidden lair”.

The only prepositions that the parser seems to understand by default are positional: the key on the table versus the key under the rug, that kind of thing.

Of course this sort of stuff can be laboriously hacked together on individual objects and/or via a maze of conditionals, but that kinda defeats the purpose of what I’m trying to do.

I think how I’m going to approach this is by adding properties onto the classes used to handle the “abstract concepts”—earlier I had been calling these DynamicWords and then DynamicThings, but currently the code uses Concept for this bit (the cave in our example) and DynamicThing for the objects that refer to the concepts (the key in our example)—and just require the implementer to manually declare a prepositional phrase to use with each state.

2 Likes

I’m excited to see what the finished product is going to look like. ^^

1 Like

At this point I’m pretty excited to see what it’s going to look like too, because it feels like I keep banging my shins against sharp corners on the parser.

LIke…is there even a way to handle a formulation like “the key to the hidden lair” except a kludge like:

Thing '(key) (to) (the) (hidden) lair lair/key' 'key to the hidden lair'
        "It's the key to the hidden lair. "
;

…which “works”, but is actually declaring an object that thinks it is both (noun-wise) a key and a lair.

Answering my own question from above: kinda.

You can do something like:

+Thing '(brass) key (to) (the) (hidden) (lair) key' 'brass key'
        "It's the key to the hidden lair. "
;
+Thing '(crumpled) map (to) (the) (hidden) (lair) map' 'crumpled map'
        "It's a map to the hidden lair. "
;

Note the duplication of the noun as an adjective in vocabWords. This (appears to) produce the expected results:

Void
This is a featureless void.

You see a pebble, a crumpled map, a brass key, and an ordinary key here.

>x brass key
It's the key to the hidden lair.

>x key to hidden lair
It's the key to the hidden lair.

>x hidden lair key
It's the key to the hidden lair.

>x hidden lair
You see no hidden lair here.

>x lair
You see no lair here.

1 Like

I’ve used those strings of parenthesized words in vocab many times…

1 Like

Same, but just to be clear: the trick here isn’t using weak tokens, it’s that you can’t use one specific weak token (the one you also want to use as a noun).

It’s what looks like a misfeature in adv3. Specifically in Thing.matchName(): the first thing matchName() does with a token list is check if all of the tokens are weak, and if they are, don’t match. This makes sense for adjectives, but it seems odd to refuse to match any tokens, even if they’re declared as part of the canonical name of the object, if they’re also declared as weak adjectives. So something like:

+Thing '(pebble) pebble' 'pebble' "It's a small, round pebble. ";

…will never match, e.g. >X PEBBLE. Which seems almost but not quite as counterintuitive (to me, at any rate) as:

Thing 'small round pebble' 'pebble' "It's a small, round pebble. ";

…matching e.g. >X SMALL ROUND ROUND SMALL SMALL SMALL ROUND ROUND ROUND PEBBLE.

Anyway, my initial attempt to have DynamicThing glom the vocabulary of an associated Concept involved just adding the Concept’s nouns and adjectives all as weak adjectives on the DynamicThing…but that doesn’t work.

What seems to work is to add all of the tokens as adjectives, but only adding them to weakTokens if they’re not in the DynamicThing’s noun list. At least it works as far as I’ve tested things.

The code in the repo is now approximating what I want. Here’s a pared-down, comments-stripped version of the eventTest.t demo from the module:

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

#include "dynamicThing.h"

abstractCave: Concept 'cave';
+ConceptStateDefault 'mysterious cave' 'mysterious cave';
+bobState: ConceptState
        '(secret) hideout' 'Bob\'s secret hideout' +1 'bobFlag'
        isProperName = true
;
+killerState: ConceptState
        '(hidden) lair' 'the killer\'s hidden lair' +1 'killerFlag'
        isProperName = true;
;
+ConceptState '(secret) lair' 'the secret lair of Bob, the killer' +2
        isProperName = true
        conceptCheck() {
                return(bobState.conceptCheck() && killerState.conceptCheck());
        }
;

caveEntrance:      Room 'Entrance to {a caveTitle/him}'
        "This is the entrance to {a cave/him}.  There's large steel door
        on the north wall with a sign on it. "
        north = caveDoorOutside
;
+Fixture 'sign' 'sign'
        "The sign says, <q>Bob's Secret Hideout</q>.
        <.reveal bobFlag> "
;
+me: Person;
+knife: Thing '(bloody) butcher knife' 'butcher knife'
        "A butcher knife.  The blood on the blade indicates it is
        the murder weapon.
        <.reveal killerFlag> "
;
+caveKey: Key, DynamicThing
        '(blood) (stained) (blood-stained) brass key' 'brass key'
        "It's a slightly blood-stained brass key. "

        dynamicThingConcept = abstractCave
        dynamicThingPrep = 'to'
        dynamicThingReady = nil

        iobjFor(UnlockWith) {
                action() {
                        inherited();
                        setDynamicThingReady(true);
                }
        }
;

+caveDoorOutside: LockableWithKey, Door 'door' 'door'
        destination = livingRoom
        initiallyLocked = true
        keyList = [ caveKey ]
;

livingRoom: Room 'Living Room In {a caveTitle/him}'
        "This is the living room in {a cave/him}.  A door leads south. "
        south = caveDoorInside
;
+caveDoorInside: Lockable, Door -> caveDoorOutside 'door' 'door';

versionInfo:    GameID;
gameMain: GameMainDef initialPlayerChar = me;

This gets us:

  • The ability to define an abstract concept that you can then use in message parameter substitutions anywhere (in this case using e.g. {the cave/him}).

  • The ability to declare objects that automagically get their vocabulary from these concepts (BRASS KEY becomes MYSTERIOUS CAVE KEY).

  • Automagic ability to use specified prepositions with the concept vocabulary (MYSTERIOUS CAVE KEY is also KEY TO THE MYSTERIOUS CAVE).

  • Automagically updating the vocabulary based on player knowledge (KEY TO THE MYSTERIOUS CAVE becomes KEY TO THE KILLER'S HIDDEN LAIR when the player learns the cave is the killer’s hidden lair).

  • Ability to toggle the vocabulary updates on and off (so the key is never anything other than BRASS KEY until the player unlocks the door with it and so learns what lock it goes to).

A transcript:

Entrance to a Mysterious Cave
This is the entrance to a mysterious cave.  There's large steel door on the north wall
with a sign on it.

You see a butcher knife, a steel key, and a brass key here.

>x brass key
It's a slightly blood-stained brass key.

>x key to mysterious cave
You see no key to mysterious cave here.

>take brass key
Taken.

>unlock door
(with the brass key)
Unlocked.

>x key to mysterious cave
It's a slightly blood-stained brass key.

>x key to hidden lair
You see no key to hidden lair here.

>x knife
A butcher knife.  The blood on the blade indicates it is the murder weapon.

>x key to hidden lair
It's a slightly blood-stained brass key.

>x key to the killer's hidden lair
It's a slightly blood-stained brass key.

>x hidden lair key
It's a slightly blood-stained brass key.

>x sign
The sign says, "Bob's Secret Hideout".

>x key to the secret lair of bob, the killer
It's a slightly blood-stained brass key.

I think I probably want to at least optionally update the disambiguation name and the name-name of active DynamicThing instances. But I’m not sure if that’s a good idea or not (it might be confusing if the contents of your inventory keep changing based on what you know, but it might make sense in some situations).

3 Likes

I think the major trick can lie in cmdDict.addWord and removeWord: I suspect that removing the weak token and replacing it with a normal token during the revealing.

With the brass key example, when revealing removing, for example, (lair) then adding lair (no parens, that is, a normal token) can be a major improvement ?

Best regards from Italy,
dott. Piergiorgio.

1 Like

Kinda.

In the code currently up in the repo new vocabulary is added (via addWord(), as you say) but old vocabulary is never removed. Maybe I’ll add that later as a configurable option, but as a general rule I think it’s better to continue to “honor” old vocabulary so you never give the player a situation where the way they’ve been referring to an object suddenly doesn’t work anymore (when they still have/can see/whatever the object).

With the weak tokens, the “trick” is just to never add a weak token that collides with a token in the object’s noun list. This is because internally there’s not really a distinction between “normal” tokens and weak tokens per se—the various vocabulary-related properties (noun, adjective, and the more esoteric ones) are just tokens (without anything noting whether they’re normal or weak) and independently the object has a weakTokens list.

If all of the tokens in a parsed expression match elements of the weakTokens list, then matchName() fails (or it declares the expression as not matching) regardless of the state of anything else.

So it’s possible to add a token, say “key”, to both the noun and adjective list, and this will affect how the expression containing it is processed…but only if “key” is not in weakTokens.