A TADS3 module for implementing rule-based scenes

There are existing contrib modules for scenes and rulebooks, but they don’t do what I need so I wrote some code.

First, a simple rules engine: ruleEngine github repo.

Second, module for rule-based scenes: scene github repo.

I won’t go over all of the internals here unless someone wants more details, but the basics are:

        // You have to declare one (and only one) scene controller.
        mySceneController: SceneController;

        // Delcare a simple scene.
        myScene: Scene
                sceneAction() {
                        "<.p>This is a scene, doing nothing. ";
                }
        ;

This creates a scene that writes a single line of output every turn. Or rather it would if it was active, which it is not by default. You can manually toggle the scene with:

        // Enable the scene.
        myScene.setActive(true);

        // Disable the scene.
        myScene.setActive(nil);

That works, but isn’t very useful. To automatically start and stop scenes you can instead define triggers:

        // Delcare a scene that's active when >TAKE is used.
        myScene: Scene
                sceneAction() {
                        "<.p>This is the scene, noticing that you
                        used TAKE. ";
                }
        ;
        +Trigger
                action = TakeAction
        ;

This will make the scene active on any turn where >TAKE is the action.

The Trigger class understands multiple properties as test criteria:

  • srcObject the object taking the action (usually gIobj)
  • srcActor the actor taking the action (usually gActor)
  • dstObject the object receiving the action (usually gDobj)
  • dstActor the actor receiving the action (either gDobj if it’s an Actor, or gDobj.getCarryingActor() if it’s a non-Actor Thing)
  • action the action itself (usually gAction)
  • room the location of the action (usually gActor.getOutermostRoom())

So, for example, if you wanted the scene to be active only when Bob tries to take the pebble:

        +Trigger
                srcActor = bob
                dstObject = pebble
                action = TakeAction
        ;

By default, an ordinary Scene runs only when active and is active only if a) something has explicitly called setActive(true) on the Scene instance, or b) all of the conditions defined on the scene are matched for the turn.

If you want a scene that runs continuously once triggered (or runs until stopped by a different trigger), there’s SceneDaemon:

daemonScene: SceneDaemon
        unique = true

        sceneStartAction() {
                "<.p>This is the scene daemon starting. ";
        }
        sceneStopAction(v?) {
                "<.p>This is the scene daemon stopping. ";
        }
        sceneAction() {
                "<.p>This is the scene daemon, first started
                        <<toString(getDuration())>> turns ago.\n ";
        }
;
+SceneStartMatchAny;
++Trigger
        srcObject = pebble
        action = TakeAction
;
+SceneEnd;
++Trigger
        srcObject = pebble
        action = DropAction
;

This creates a scene that is started by >TAKE PEBBLE and stopped by >DROP PEBBLE:

>take pebble
Taken.

This is the scene daemon starting.

>x pebble
A small, round pebble.  Picking it up starts the scene.

This is the scene daemon, first started 1 turns ago.

>drop pebble
Dropped.

This is the scene daemon stopping.

There are also utility classes for scenes that either allow all actions except those defined as triggers, or deny all actions except for those defined as triggers:

SceneDefaultAllow;
+Trigger
        srcObject = pebble
        action = TakeAction
;

This allows all actions except >TAKE PEBBLE:

>x pebble
A small, round pebble.

>take pebble
You can't do that here.

The message is either the scene’s sceneBlockMsg or playerActionMessages.sceneCantDefaultAllow.

The deny variation is the inverse:

SceneDefaultDeny;
+Trigger
        action = static [ TakeAction, QuitAction ]
;

This creates a scene that blocks all actions except >TAKE and >QUIT:

>x pebble
You can't do that here.

>take pebble
Taken.

In this case the block message is either the scene’s sceneBlockMsg or playerActionMessages.sceneCantDefaultDeny.

All of this logic is built on the Rule, Rulebook, and RuleUser logic provided by the ruleEngine module. I won’t go into all of that here, except to point out a couple features:

  • All the examples above involve adding Trigger instances directly to Scene instances. Trigger is a subclass of Rule, and Scene is a subclass of RuleUser. Adding a Rule directly to a RuleUser creates a Rulebook with the ID “default”
  • The default behavior described above notwithstanding, you can declare multiple Rulebooks on scenes if you need them
  • A Rulebook instance’s state is nil by default, and it becomes true when all the rulebook’s rules are matched. But there are subclasses with different behaviors: RulebookMatchAny, which becomes true if any of its rules match; and RulebookMatchNone, which has a default state of true and becomes nil if any of its rules are matched. There’s also a RulebookMatchAll which is just a synonym for the base Rulebook class.
  • In addition to using Trigger instances, you can use Rule instances. A Rule is just a l’il object that implements an arbitrary state check and provides a matchRule(data?) method that returns boolean true on a match and a nil otherwise.

The code is (hopefully) extensively commented and there are little demo “games” in the ./demo subdir of each repo.

The main “gotcha” here is that as currently written all Rule instances get evaluated every turn. In a “real” rules engine there would be a graph/tree/grammar that would short-circuit rule chains so as to not evaluate rules whose state doesn’t matter (if a you’re looking at a rulebook that’s true if all its rules match and the first rule doesn’t, then you don’t need to check the rest). This is kinda a side-effect of wanting to be able to implement arbitrary checks in Rule.matchRule() without implementing a whole new grammar for rules. I might do a parse tree sort of thing with Trigger instances later, though.

9 Likes

Really thanks ! SceneDefaultAllow and more so SceneDefaultDeny is an excellent starting point for what I really need in my major WIP (developed with Adv3Lite)

Best regards from Italy,
dott. Piergiorgio.

I wish I could picture how/what I’d use this for, because it sounds interesting…

Hm, not sure if my code will work in adv3lite; I’m developing in adv3. But if the module doesn’t compile directly, my guess is that porting it to adv3lite should be fairly straightforward.

And yeah, the “default allow” and “default deny” bits were one of the starting points for me as well. I started out with a scene system that was very similar to the one in the contrib module from @Eric_Eve: basically a daemon with a method defining when the scene starts and a method defining when the scene ends. I separately had a bunch of Agenda-based logic for handling specific action triggers and that sort of thing (triggering some behavior in an NPC when they notice the player doing some action). Then there was another bunch of code for handling abstract “quest” state tracking. And also a separate bunch of code for handling a couple of specific gameplay windows where I wanted to block most of the player’s actions and provide situation-specific responses.

I think I reduced and simplified all of that stuff into the core mechanics of the rule engine and scene logic, although I haven’t remotely finished re-implementing all of the old spaghetti code using the new rule-based logic.

Anyway, in terms of specifically handling SceneDefaultAllow and SceneDefaultDeny, keep in mind that the block-and-output-a-failure-report is just the default behavior. If you look at sceneDefaultAllowDeny.t you can see that the classes are pretty simple, for example:

class SceneDefaultDeny: Scene
        rulebookClass = RulebookMatchNone
        sceneBlockMsg = nil
        sceneBeforeAction() {
                if(sceneBlockMsg != nil)
                        reportFailure(sceneBlockMsg);
                else
                        reportFailure(&sceneCantDefaultDeny);
                exit;
        }
;

Most of the behavior is determined by the rulebookClass definition (most of the difference between SceneDefaultAllow and SceneDefaultDeny is because the first uses rulebookClass = RulebookMatchAny and the second uses rulebookClass = RulebookMatchNone). So you can customize the “allow”/“deny” behavior, for example, by just providing the scene instance with a different sceneBeforeAction().

It’s worth pointing out that there’s nothing that you can do with this kind of setup that you couldn’t with “stock” T3. It’s just something that (hopefully) makes handling specific kinds of complexity a little less messy.

Say you have a game where the goal is to collect some number of treasures and then deposit them in a trophy case. If the player wins when the final treasure goes in the trophy case, you can just check the game state every time the player puts something in the case, and trigger the endgame when you see that the conditions have been met. That is, the “quest” logic in this case might be built into the treasure case, or maybe the verb(s) used to interact with it.

That gets a little messier if your win condition is more complicated: you have to check to see if the player has slain the dragon, rescued the maiden, and put all the treasures in the treasure case. Or, even more elaborately, you have to check that endgame condition or: has the player rescued the dragon, slain the maiden, and donated the treasures to the Temple Of Moderate Irascibility…or are the dragon and the maiden living quietly in one of the five suitable seaside cabins and does the cabin they’re in contain at least one of the treasures, and is that treasure one of the ones listed on their wedding registry.

In this case you could build all the logic into the individual bits and pieces whose state collectively defines the endgame condition. But that’s probably going to get very messy and difficult to manage/update/troubleshoot. One option is to create a singleton or something like that, and have it keep track of the various bits of game state (when the dragon enters an “interesting” state, it pings gameState.updateDragon(currentState) or something like that). This tends to work fine if all state transitions are permanent but can get messy for non-permanent states: the dragon is likely to stay slain but if objects can be removed from the treasure case, then you have to take care that tracked state doesn’t get out of sync with the “real” state.

That’s basically the idea behind things like rule engines. I’ve got a bunch of individual conditions that I might want to check for. Each one is a Rule, and any time I want to check if some game-relevant condition, I can just ping the rule. The three different endgame states described above are different Rulebooks (which are basically just containers for Rules), and the main quest/game progress is in this case a RuleUser instance (which is just a container for multiple rulebooks and some logic for checking their states).

The basic idea being that if you, as the game designer, want to look at what’s required for the player to accomplish some meta-goal (that is, a game state that’s more complicated than “does the player have the widget” and that kind of thing), that logic is going to be spelled out in a list in one place. And if you want to provide a summary of their progress to the player (like a quest checklist kinda thing) you have something that’s easily mappable to one.

1 Like

And an update: I pushed a minor update to the ruleEngine module providing a RuleEngineOptimized class, and an update to the scene module making SceneController a subclass of RuleEngineOptimized by default.

The “optimized” version of the rules engine attempts to short-circuit evaluation of rulebooks, only evaluating as many rules in each rulebook as needed to determine the current state.

This isn’t a proper parse tree or anything like that, but it should be slightly more performant in general, and much more performant for rulebooks with lots of expensive-but-unlikely rules. There’s a test case in ruleEngine/demo/src/optimizedTest.t that uses hundreds of random rules, some of which do tens of thousands of arbitrary BigNumber operations which takes several seconds between turns “unoptimized” but has no noticeable lag “optimized” (because the vast majority of the rules will never be evaluated).

1 Like

Should point out there is a basic Scene class in adv3Lite (explained here, class docs here).

It doesn’t appear as fleshed out as jbg’s implementation, but does offer a similar way to organize scenes in a game.

Another way to think of this is that it makes handling “large” game changes and event triggers more declarative, rather than procedural. Scene changes could be about a change of location, a change in time, or a major shift in the game. (There’s a good explanation of them in the adv3Lite docs, page 245.)

As jbg said, you could find a place in the code for each scene change (when the user enters location X carrying the object Y, or pulls levers in locations A, B, and C, etc.) but that can be pretty messy, as it sprays all that logic all over your game code.

With declarative-style programming, you’re doing something like this (using the adv3Lite Scene class):

Scene
  // scene starts when player enters X holding Y
  startsWhen = (gPlayerChar.isIn(locationX) && objectY.isIn(gPlayerChar))

  // executed when Scene starts
  whenStarting() {
    "Suddenly, you feel a rumbling beneath your feet...the lantern begins glowing....";
    objectY.isGlowing = true;
  }

  // executes each turn during this Scene ... if another Scene starts, this will stop executing
  eachTurn() {
    if (objectY.isIn(gPlayerChar))
      "The lantern continues glowing.";
  }
; 

If you have a lot of these scenes, it’s much easier to keep these in a central place (or a separate file), which makes updating their conditions a snap. Note that doing it this way, you don’t need to add code to the location or Thing objects; it’s all contained in the Scene.

(My WIP has a number of transitions between multiple player characters in time and space. I use a system similar to the above. Declarative-style code makes it quite easy to modify the conditions for transitions without having to dig procedural code out of one in-game Thing and move it to another Thing. So, if a scene starts when the player has talked to two NPCs, and I later decide “No, they have to talk to three NPCs,” it’s trivial to make that change. I don’t even have to touch the NPC code.)

4 Likes

It’s also much easier to handle “floating” scenes (and scene-like things) this way.

For example, if you’ve got a mystery story and you’ve got, I dunno, a bagel thief. And every time the bagel thief steals a bagel, you get a crime scene. But the crime scenes aren’t fixed, they can happen anywhere someone’s put some bagels. And the player (and NPCs) can put bagels more or less anywhere.

If you want to restrict the number of actions the player can take in a crime scene—only use basic “sense” actions plus a few special investigative verbs, for example—then where do you put that logic? If any Room is potentially a crime scene and every Thing is potentially a bit of evidence, then the “normal” way of handling checks via dobjFor([action]) and so on can involve putting the code in every game object. And however many special cases you want to handle end up spread out across however many objects the special cases apply to.

2 Likes

A couple minor updates: I added a RulebookPermanent subclass of Rulebook (suitable to use as a mixin) and a finalizeRulebook() method on RuleUser. Both of these provide lifecycle chrome, specifically for one-and-done “stuff”.

RuleUser.finalizeRulebook() takes a single argument (a Rulebook). The given rulebook is removed from the RuleUser’s rulebook table (meaning it will no longer be checked), and added to a separate table for “finalized” rulebooks (meaning it won’t be garbage collected). This is provided in the assumption that some rulebooks will represent game state that other things will want to refer to even after it’s no longer being actively updated.

And RulebookPermanent is just a type of rulebook that permanently locks its state the first time it changes from the default state. It also calls finalizeRulebook() on its owner (with itself as the argument).

SceneDaemon has been updated to automagically call finalizeRulebook() on all its rulebooks when the scene ends, if the scene is marked as unique.

1 Like

I see, there’s many questions…
After a certain misunderstandment, I prefer to downplay the main rationale (PC can’t move when in a deep hug, and can’t speak when kissing) and I apologise to Zieg for his bewilderment… plus of course the NPC react to PC’s actions, and indeed there’s a major state tracking (the PC’s understanding of the unusual situation is in), so I and Jbg share many coding needs. and as JimN points, code can easily became messy.

My tentative idea is doing something akin to an a3Lite extension for a3Lite’s scenes.t (whose always has one, scenetopics.t) and Eric’s original scenes.t contrib library for adv3 was the starting point for a3lite’s scenes.t, I think is feasible extending a3lite’s scenes.t with an appropriate extension.

(hope that the terminology I used isn’t confusing…)

Best regards from Italy,
dott. Piergiorgio.

1 Like

started to analyse the sources, and there’s a strange reference to a non-existent repo:

#error "This module requires the beforeAfter module."
#error "https://github.com/diegesisandmimesis/beforeAfter"

not a bug, I think. An oversight ?

Best regards from Italy,
dott. Piergiorgio.

1 Like

Ooops. Yeah, I thought I’d made that repo public a while back. Should be visible now.

1 Like

I pushed a minor update to how rule checks work.

Normally rules keep track of when they were checked, and if the turn number is the same as the last time they were checked they’ll return the cached value of their last check instead of running the full check again.

This caused action triggers to fail for implicit actions, because the rules would first be checked for the original action, cache their state, and then not check again for the implicit action (which happens by default in the same turn number as the original command).

The update requires a full re-check whenever the current action is a nested action of any kind.

1 Like

In the end, studying this adv3 library led me to discover, as usual with TADS3 documentations, that I was again sit on the solution, built-in in a3Lite, and an elegant one, on top of it…

Best regards from Italy,
dott. Piergiorgio.

A bump for a few updates to the module.

A number of templates and convenience classes have been added to make it easier to declare scenes, particularly SceneDefaultDeny (which block everything except specific allowed actions) and SceneDefaultAllow (which permit all actions except specific denied actions).

Some logic has also been added to make it easier to declare scenes that are restricted to a specific location or the presence of a specific object.

Simple example, followed by a breakdown of the moving parts:

startRoom: SceneRoom 'Void'
        "This is a featureless void."
        north = otherRoom
;
+me: Person;
+pebble: Thing 'small round pebble' 'pebble' "A small, round pebble. ";

otherRoom: Room 'Other Room'
        "This is the other room."
        south = startRoom
;
+rock: Thing 'ordinary rock' 'rock' "An ordinary rock. ";

SceneDefaultDeny @startRoom 'This is the default deny message.';
+AllowTravel;
+AllowSenseActions;
+AllowAction @TakeAction;
+AllowAction @QuitAction;

The first thing to note is the startRoom declaration, which uses the SceneRoom class. This is equivalent to declaring startRoom’s class list to be RuleScheduler, Room, and is just an easier way to declare that the room should be a rule scheduler.

Having done that, the scene declaration can define its location to be startRoom and then its rules will only be checked if the player is in the room.

In the example the location is declared via template:

SceneDefaultDeny @startRoom 'This is the default deny message.';

This means it’s a scene that by default denies all actions except the ones explicitly allowed; its location is startRoom; and the message displayed when blocking an action will be “This is the default deny message”.

The lines following the scene declaration (with the +s) are the actions to allow.

AllowTravel is a utility class that permits any TravelAction or TravelViaAction.

AllowSenseActions is a utility class that permits any “sense” action: ExamineAction, SmellAction, ListenToAction, and so on.

The AllowAction declarations permit the specific named actions. I.e. AllowAction @TakeAction permits >TAKE while the scene is active. This could be changed to only apply to specific objects, for example to only allow the pebble to be taken this would be:

+AllowAction @TakeAction ->pebble;

The AllowTravel, AllowSenseActions, and AllowAction classes are are subclasses of SceneTrigger, in turn a subclass of Trigger (from the parent ruleEngine module). The “Allow” classes are only used for SceneDefaultDeny scenes. There are corresponding “Deny” classes for use with SceneDefaultAllow: DenyTravel, DenySenseActions, and DenyAction.

Finally, there’s a SceneTrigger subclass ReplaceAction that will block a matching action and halt command evaluation. Note that rule evaluation short-circuits, so in a SceneDefaultDeny or SceneDefaultAllow if processing matches an exception before a ReplaceAction, the replacement action won’t be evaluated. So with:

// THIS EXAMPLE WILL NOT WORK.
SceneDefaultDeny @startRoom 'This is the default deny message.';
+AllowTravel;
+AllowSenseActions;
+AllowAction @TakeAction;
+AllowAction @QuitAction;
+ReplaceAction @ExamineAction 'This is the deny message for <q>examine</q>.';

…the command >EXAMINE PEBBLE would not trigger the ReplaceAction rule, because any >EXAMINE command would be handled by AllowSenseActions earlier in processing. In order to make this work, you’d have to place the ReplaceAction before any other matching rules:

SceneDefaultDeny @startRoom 'This is the default deny message.';
+ReplaceAction @ExamineAction 'This is the deny message for <q>examine</q>.';
+AllowTravel;
+AllowSenseActions;
+AllowAction @TakeAction;
+AllowAction @QuitAction;

…for example.

Also note that this example uses the template to declare a message to display, and the action replacement doesn’t make other changes to the game state. If you want to do something more elaborate, you can declare something like:

ReplaceAction @ExamineAction
     sceneAction() {
          // cool stuff
     }
;

…to execute whatever’s in sceneAction() when the rule matches.

Simple complete example (equivalent to ./demo/src/defaultDenyMulti.t from the module):

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

#include "scene.h"

versionInfo: GameID;
gameMain: GameMainDef initialPlayerChar = me;

startRoom: SceneRoom 'Void'
        "This is a featureless void."
        north = otherRoom
;
+me: Person;
+pebble: Thing 'small round pebble' 'pebble' "A small, round pebble. ";

otherRoom: Room 'Other Room'
        "This is the other room."
        south = startRoom
;
+rock: Thing 'ordinary rock' 'rock' "An ordinary rock. ";

SceneDefaultDeny @startRoom 'This is the default deny message.';
+ReplaceAction @ExamineAction 'This is the deny message for <q>examine</q>.';
+AllowTravel;
+AllowSenseActions;
+AllowAction @TakeAction;
+AllowAction @QuitAction;

Thrilling transcript:

Void
This is a featureless void.

You see a pebble here.

>take pebble
Taken.

>drop pebble
This is the default deny message.

>x pebble
This is the deny message for "examine".

>n
Other Room
This is the other room.

You see a rock here.

>drop pebble
Dropped.

>x pebble
A small, round pebble.

2 Likes

as discussed earlier elsewhere, this module is what I actually needed; later I’ll look into it, and see how to port it into a3Lite.

Best regards from Italy,
dott. Piergiorgio.

1 Like

The module’s been updated again.

System actions can now be allowed/ignored (correctly) via the same mechanisms as other actions. In order to enable this on the scene, you have to add ignoreSystemActions = nil to the scene declaration.

The actual code changes are in the ruleEngine module that the scene module depends on. So the change also affects anything else that uses ruleEngine directly (although I doubt there’s anything out there that fits that description except the related modules).

The module now also depends on the timestamp module (also via the upstream ruleEngine dependency).

2 Likes