Skip to content

Behavior creation

Protected edited this page Nov 17, 2023 · 4 revisions

Implement a new Behavior to provide a new set of features for Rowboat. New Behaviors should be placed in behavior/MyBehavior.js, where Behavior should be the name of your new Behavior type.

Start the name of your Behavior with Custom, as in CustomMyBehavior, if you're creating a local-only behavior that should not be committed to the repository, since that prefix is in gitignore.

All Behaviors must extend the Behaviorclass. The following is an example barebones Behavior that shows the methods you should override (note the behavior type being passed to the superclass in the constructor):

import Behavior from "../src/Behavior.js";

export default class MyBehavior extends Behavior {

    get description() { return "Description of the Behavior type."; }

    get params() { return [
        //List of parameters
    ]; }
    
    get defaults() { return {
        //Map of parameter default values
    }; }
    
    get requiredEnvironments() { return {
        //Map of parameters pointing to Environment instances that must exist
    }; }

    get requiredBehaviors() { return {
        //Map of parameters pointing to Behavior instances that must exist
    }; }
    
    get optionalBehaviors() { return {
        //Map of parameters pointing to Behavior instances that may or may not exist
    }; }
    
    get isMultiInstanceable() { return false; }

    constructor(name) {
        super('MyBehavior', name);
        
    }
    
    initialize(opt) {
        if (!super.initialize(opt)) return false;

        //Do things
      
        return true;
    }

}

Parameters

Parameters in Behaviors function much like they do in Environments.

Go to Config files and parameters to learn how to provide values for them.

Dependencies

Behaviors can reference Environments and other Behaviors in two ways: Through the declaration of dependency parameters (recommended), or directly through lower level methods, which can be useful if you need to implement different ways to store and access these references.

With rare exceptions, cross-Behavior method calls are proxied and asynchronous.

Dependency parameters

The requiredEnvironments, requiredBehaviors and optionalBehaviors overrides that you can see in the example at the top all return maps of the PARAMETER: TYPE format.

For example, if you add myEnvironment: MyEnvironmentType to requiredEnvironments, a parameter myEnvironment is implicitly declared (though you can, you don't need to declare it in params()) that is expected to contain a reference to an Environment of the type MyEnvironmentType in the config file.

If myEnvironment is missing from the config file or doesn't reference a valid MyEnvironmentType instance, startup will fail. That means you can be confident that an instance of MyEnvironmentType will be available to your Behavior.

In order to minimize configuration errors, the following rules apply to dependency parameters:

  • requiredEnvironments parameters are mandatory parameters and must be explicitly provided in config. If you want to use the same Environment for everything that requires that type of environment, you can provide it through behaviorCommon.
  • requiredBehaviors parameters are optional parameters; their default value is the same as the Behavior type, which is the default instance name for Behaviors. The target Behavior is still required (it must be loaded), but you don't need to reference it explicitly in the config for the Behavior you're writing, unless you want to redirect the dependency to a different Behavior name.
  • optionalBehaviors parameters are optional parameters; their default value is null, which means the target Behavior will not be available to the Behavior you're writing. It will only be available if the parameter is explicitly provided in the config.

Accessing dependencies

To obtain a proxy for a dependent Environment or Behavior, you can use this.env("myEnvironment") and this.be("myBehavior"), where myEnvironment and myBehavior are the names of the parameters that contain the reference to the desired Environment or Behavior.

You can then call methods on the remote Environment or Behavior, without forgetting that the result will be a Promise. Additionally, any properties of the remote class whose name begins with an underscore can't be accessed remotely. For example:

class MyBehaviorA extends Behavior {

    get requiredBehaviors() {
        BehaviorOfTypeMyBehaviorB: "MyBehaviorB"
    }

    constructor(name) {
        super('MyBehaviorA', name);
    }

    initialize(opt) {
        if (!super.initialize(opt)) return false;

        this.be("BehaviorOfTypeMyBehaviorB").usefulMethod()
            .then(result => {
                //result = 42
            });

        return true;
    }

}

class MyBehaviorB extends Behavior {

    constructor(name) {
        super('MyBehaviorB', name);
    }

    usefulMethod() {
        return 42;
    }

}

requiredEnvironments and requiredBehaviors are guaranteed to exist. Before calling a method on an optionalBehavior, you can check if it exists using this.optBeExists("myBehavior").

Finally, use this.env() without arguments to obtain a proxy that calls methods in every initialized Environment at the same time. This is mainly useful if you want to register an event on every Environment, such as if you want your Behavior to listen to messages on every Environment. When using this.env() without an argument, you don't need any explicit reference in requiredEnvironments.

Lower level methods

If you want to reference Environments and Behaviors directly without requiring config file parameters, that can be done using methods that Core passes to your initialize method.

    initialize(opt) {
        if (!super.initialize(opt)) return false;

        let {envExists, beExists, envProxy, beProxy} = opt;

        if (envExists("MyEnvironment", "MyEnvironmentType")) {
            envProxy("MyEnvironment").msg("Protected", "Hello!");
        }

        if (beExists("MyBehavior")) {
            beProxy("MyBehavior").usefulMethod();
        }

        return true;
    }

envExists and beExists will return true if an Environment/Behavior with the given name have been loaded. If a second argument is passed, the type of the Environment/Behavior is validated to be equal to that argument.

envProxy/beProxy will create a proxy for the Environment/Behavior with the given name.

Multi-instanceable paradigm

There are two ways to create a Behavior that manages multiple resources of the same type.

By default, Behaviors are single instanceable. This means that even if you declare the Behavior in the config file with an instance name, Rowboat won't allow you to load more than one instance of it. Behaviors like this can still manage multiple resources internally using their own data structures to wrangle the different resources.

Instead, you can choose to create a multi instanceable Behavior by overloading the isMultiInstanceable() getter:

    get isMultiInstanceable() { return true; }

This will allow Rowboat to load multiple copies of the same Behavior, as long as a different name is assigned to each of them.

Take this into account when designing your Behavior; for example, you must ensure that the instance name is used in data file names and in text commands, otherwise the multiple instances of the Behavior will conflict with each other.

Initialization

A Behavior is a blob of functionality that can do anything. As you can see from the example at the top, the only point of execution for Behaviors is the initialize(opt) function, which you should always override from the base Behavior class. You should use initialize to set up the Behavior, returning true if setup is successful or false if it's not. Auxiliary methods can be declared in the class itself, alongside initialize.

The following is a list of things you may want to do in your initialize method. Depending on what the Behavior you're implementing is supposed to do, some or all of these suggestions may not apply.

  1. Load data: If your Behavior persists data to the hard drive, you'll need to load previously persisted data. You can use Rowboat's built in datastores for very basic data persistence, or you can import your own node.js solution if you need something like a database connection.

  2. Cleanup handlers: If your Behavior has ephemeral data (doesn't immediately save important data to the hard drive), or if it needs to log out of a service before Rowboat shuts down, or any number of other scenarios, it should register a shutdown or cleanup handler with the Core that can perform the necessary housekeeping before shutdown.

  3. Add event listeners: Obtain references to dependencies and use the on method to register event listeners that react to messages from remote services or triggers from other behaviors. The callback will be passed the arguments that the event is emited with. For example:

this.env("myEnvironment").on("connected", (env) => {
    //Do things
});

Rowboat events can be interrupted if you return true from a handler. If you do this, subsequent handlers for the same event will not be called. Declare higher priority Behaviors earlier (higher) in the behaviors list of the config file to prevent them from being interrupted by Behaviors declared further down.

  1. Poll or connect to external services: You may want to establish connections to external services that are not Environments, either using third party node.js libraries or using built in HTTP request methods.

  2. Start timers/intervals: If your Behavior needs to do something recurrently, you can use standard Javascript setTimer and setInterval here.

  3. Register commands or other Behavior specific interactions: Some Behaviors, like the Commands Behavior, provide services for other Behaviors. For example, you might want to call this.be("Commands").registerCommand(...) to register text commands.

Datastores

Behaviors can persist data to the hard drive using datastores, which are a simple wrapper around JSON serialized objects. Initialize a datastore using a pattern like this:

    #myData = {};

    initialize(opt) {
        if (!super.initialize(opt)) return false;

        this.#myData = this.loadData();
    }

By default, this.#myData is a regular object which can be used as a map. Use the .save() method to persist the object to the hard drive:

    setColor(color) {
        this.#myData.color = color;
        this.#myData.save();
    }

The object will be persisted to data/BehaviorName.data.json, where BehaviorName is the name of the Behavior in the config file. Both parts of this path can be changed: You can declare the parameter name datafile to let users change the filename, and the data path can be found in the paths section.

Multiple files can also be used by the same behavior if you manually set the filename on load:

this.#anotherData = this.loadData(this.name + ".another.json", []);

The example above has been initialized as an empty array instead of as an object. The empty array will only be used if the file doesn't yet exist in the data path.

HTTP requests

Behaviors have some helper methods for performing HTTP requests, callable using this.METHOD(...) . These methods use the node.js native http and https packages (both protocols are supported).

  • async urlget(url[, options][, encoding]) - Sends a GET request to the specified URL and returns a Promise that resolves with the full body of the response, or rejects with the object {error, statusCode}.
  • async urlpost(url[, content][, options][, encoding]) - Similar to urlget, but using a POST request, which can contain a body.
  • async jsonget(url[, options]) - The same as urlget, but deserializes an expected JSON response into a Javascript object.
  • async jsonpost(url[, content][, options]) - The same as urlpost, but serializes the content as JSON before POSTing and deserializes the response into a Javascript object.

The options for these methods can be:

  • buffer (true|false): Whether to concatenate the body using a Buffer instead of as a string.
  • returnFull (true|false): Instead of resolving with just the body of the response, resolves with {body, cookies, statusCode} (not usable with the json* methods).
  • Any http/https options (the object is passed directly to http/https).

Additional helper methods are:

  • async downloadget(url, localpath) - Downloads the specified URL and stores it in a local path. Resolves with the local path.
  • streamget(url, options[, extcallback]) - Streams the specified URL's response body into a passthrough stream, which is returned. Useful for piping into other functions that take data from streams. The options object is passed directly to http/https.

Shutdown and Cleanup handlers

Shutdown and Cleanup handlers can be registered in Behaviors just like in Environments.

Add then at the top of your initialize method to make sure all of the Behavior's data is properly persisted when Rowboat shuts down.