LotJ supports using OpenAI GPT-4 (and other models) as an engine for NPC AI and dialogue. The framework endeavors to make this as simple to implement as possible, while preserving the ability to customize and extend capabilities. This guide serves as an introduction to how to use this capability.

The basics

chatgpt flag

All GPT powered NPCs start with the "chatgpt" flag. As soon as you add this flag the NPC gains the ability to respond to "sayto" events. These responses will be based on basic information about the NPC (race, gender, shortdesc, longdesc, if it is fighting anyone) and some information about the environment (the description of the room it is in). The NPC only gets the players GName, so it will use the most generic and IC friendly terms to refer to the end user. Even this much works surprisingly well for many NPCs if they have good descriptive text, and are in a room that fits with their character.

aidesc

The next level of intelligence comes from the aidesc. Aidesc is included as an additional prompt in the context sent to OpenAI. Given its placement, it has a lot of influence on the NPCs behavior, so you can use it to define who the character is, what it knows, how it will behave, etc. By turning on the flag and then providing a rich aidesc you can get a pretty good character going for regular chat interactions.

aidesc templates

The next level up is to use templates to make your aidescs more intelligent. The aidesc is processed by the etlua templating library before it gets provided to openai. You can see the docs for rtlua here - https://github.com/leafo/etlua. Basically it lets you mix lua with other output to render the template. The template includes the npc, the speaker, and the room. Here is an example of how to use it.

You are just a simple farmer who wants to be left alone to tend to your crops.
<% if speaker:getLevel("force") > 2 then %>
You can sense that <%= speaker:getGName() %> is sensitive to the force.
You are an ancient jedi master, although you live in secret to avoid being hunted down and killed by the empire. You know that your apparent role as a simple farmer is but a disguise to keep you safe.
<% else %>
You can sense that <%= speaker:getGName() %> is not sensitive to the force.
<% end %>

Note of course that you don't want the NPC to potentially reveal things to other players in the room with its dialogue so use this thoughtfully. A really obvious application would be quest or variable driven behavior, or behaviors that should only happen if the npc is talking to a fellow clan member in a secure location.

Getting advanced

The flag and aidesc can really do a lot for you, but sometimes you want your NPCs to do more than just talk. For this, you can use traditional lua lprogs to add functionality to your gpt powered NPCs. GPT-4 has been trained to use functions, which enables it to directly interact with other systems and it makes remarkably good choices sometimes. Normally managing the function interface takes a little bit of work but our framework makes it much easier to put the pieces together.

In a nutshell, you need to define and register the functions you want your NPC to give GPT access to in the lprog for the npc itself, and the framework will do the rest. Let's consider a worked example. Note, this will only work in the npc's lprog.

local GPTNPCFunctions = Loom.include("gptnpcfunctions.loom")
local gptfuncs = GPTNPCFunctions:new()
gptfuncs:register(
{
    name = "call_backup",
    description = "Call police mobs to your location to engage with a threat",
    parameters = {
      type = "object",
      properties = {
        target = {
          type = "string",
          description = "The person to call backup on"
        },
      },
      required = {"target"}
    },
    func = function(npc, pc, args)
      LOTJ.log(table.tostring(args))
      local player = npc:getInRoom():findChar(args.target,npc)
      if player then
        return npc:callBackup(player) and "called" or "the call failed, you are on your own"
      else
        LOTJ.log("Invalid player")
      end
    end
  }
)

In the above example we include the loom for gptnpcfunctions and instantiate it. We then call the register function to register a new function. Within that definition we have a table which sets a few values - the name of the function, a description of what the function does, the parameters for the function, and then a handle to the function itself.

Note the structure for how parameters are specificed. This bleeds through directly from OpenAI's APIs, and rather than try to transform it into something even more opaque, the framework just leaves you to work with it as it is. Basically just follow the structure and you will be fine. If your function does not take parameters, you can set this to nil.

For the function definition, note the parameters that are passed in. The first is always a handle to the GPT powered NPC, and the second is always a handle to the PC who initaited the interaction (if applicable). For example, if this is all a consequence of someone using sayto on the mob, then it is the pc who used sayto that is represented here. This parameter could be nil under some circumstances. The third parameter is a table of the arguments passed in from OpenAI. The contents of this table will follow the structure that you specified in parameters above.

Now that you know how it is structured, let's look at what this function does. It is called "call_backup," and the description specifies that it will summon police mobs to your location to engage with a threat. It takes one parameter, the name of the person to call the backup "on," and that parameter is required.

When we look at the implementation, we use the findChar function to find the pc in the room. Because the PC may be a description such as "a human male" we need to pass in the NPC as well so that it will find the target based on the npc's "view" of things. This is not a behavior specific to the gpt implementation, but it is worth explaining as it is not necessarily intuitive.

If we successfully find the player, we call the callBackup function on that target, and if it returns true, we return "called," and if it returns false we return a message letting OpenAI know that the function did not achieve what it hoped. These return values are important, because they tell the AI what the result of the function was, and it will then respond/behave accordingly. They are not strictly necessary, but they do help with behavior a bunch.

Here are some additional examples for study and reference. Note, these are registered with successive calls to register. You can call this function as many times as you like.

gptfuncs:register(
{
    name = "attack",
    description = "Attack a target. Starts a fight.",
    func = function(npc, pc, args)
      LOTJ.log(table.tostring(args))
      local player = npc:getInRoom():findChar(args.target, npc)
      if player then
    LOTJ.log("Attacking...")
        return npc:attack(player) and "OK" or "Unable to attack"
      else
    LOTJ.log("Unable to get player to attack")
        return "invalid player"
      end
    end,
    parameters = {
      type = "object",
      properties = {
        target = {
          type = "string",
          description = "The person to attack"
        },
      },
      required = {"target"}
    }
  }
)

gptfuncs:register(
{
    name = "stop_fighting",
    description = "Stop fighting.",
    func = function(npc, pc, args)
      LOTJ.log("Stop fighting")
      npc:stopFighting(true)
      npc:stopHating()
      npc:stopHunting()
      npc:stopTracking()
      return "OK"
    end,
    parameters = nil
  }
)

internals

OK, so how does this all work for the curious? At the bottom we have the rpcsidecar which sends requests to openai. On top of that, we have mqtt, and the rpc interface (rpc.lua) which is used by openai.lua to expose the openai functions we want to use in lua. These are then used by basicgptbot.loom to create the basic nuts and bolts of an interaction. Basicgptbot handles things like making recursive calls for functions, storing the state of the conversation, cleaning up, json encoding/parsing where required, etc. On top of basicgptbot, we have gptnpc.loom which layers in all our special npc behaviors, includes some basline prompting, and sets up the wiring for remote calls to our lprog if you are using gptnpcfunctions. Finally, we have chatterbox.loom which registers a trigger for all sayto activity in the game, and invokes gptnpc for npcs that have the chatgpt flag.

gptnpcfunctions works through the use of the Remote interface in the LotJ lua layer. Remote allows us to call functions in other progs that have been exported. This is useful for a variety of special use cases, like privileged / non privileged separations (sending messages to discord), or accessing functionality that one prog will expose to another. In this case, it looks up the prog for the npc, and checks to see if it is exporting functions to get a list of registered functions, and to handle function calls. If it is, gptnpc bootstraps your lprog's registered functions automatically.

That's it, in a nutshell.

generated by LDoc 1.5.0 Last updated 2024-10-22 16:05:00