20101012

Mobs and the Things They Do

It's been a while since my last post, which is kind of the default state of things on most blogs, so I don't know why I even bother saying it. I've been doing some other blogging on Destructoid lately, which is pretty fun. But enough of that. I've also been working on a few changes related to mobs and how they act. These changes are the baby steps at the beginning of implementing combat, which looks like it will be the first major "game" feature I do.

Looking forward to combat, I can see it as being a kind of special case of a "mobprog," that is, a mob behavior that responds to some stimulus. You hit me, I hit you back, I dodge, cast a spell, etc.. This means that mobs will be performing series of actions in an automated way.

Currently, there are a few ways to make a mob to do something. If you can type things, you can issue commands to a mob via the usual interpreter -> interaction mode -> mob path. Or you can order a mob to do something, again using roughly the same process. Finally, you can directly invoke methods on the mob object, for instance, doLook or doLook_Direct.

The last of these ways in particular will probably be used for implementing mobprogs, so I took a long, hard look at it to see if it does everything it should. Hint: it does not, which is why I'm writing this blog right now.

It turns out the way things were implemented, direct calls on a mob sidestep a lot of the necessary mechanics that bring it to life. In particular, it circumvents delays. Delays were inserted by the interaction mode when a user types in a command. They ensure that you can't make your mob do more actions more quickly than it "should be able to."

If you think about it, the right place for delays to be inserted and handled is in the mob itself, not in the interaction mode, which is just part of the interface between the player and the mob. The amount of delay from any particular action has nothing to do with the player and everything to do with the mob. So I moved it.

Let me just interject here that the next several paragraphs are an outline of code that I rejected after realizing what a bad idea it was. If you want, you can jump straight to my epiphany, correction, and what I did implement.

Now each mob has its own invocation queue, like interaction modes do. When a user types a command, if it's an action to be carried out by the mob (an ICCommand / ICInvocation), the interaction mode just queues it up to the mob; if it's an out of character command, the interaction mode takes care of executing it, since it has nothing to do with the mob itself.

Some restrictions apply

As I've described so far, all is not well. Where there used to be a single queue of invocations, now there are two, and moreover, there are multiple ways for commands to get into the two queues:

  • A user can type an IC command, which is generally redirected from the interaction mode straight to the mob's queue.
  • A user can type an OOC command, which stays in the interaction mode and is not seen by the character or the character's queue at all.
  • Another mob can send an order to this mob, which is routed from the mob to the interaction mode and then back to the mob.
  • Something programmatic like a mobprog can directly invoke something on the mob, which is never seen by the interaction mode but goes straight into the mob's queue.

And with all these different routes there are a few invariants we want always to hold:

  • Commands should be executed "in order," i.e., commands shouldn't be able to "cut in line."
  • Commands from any one source shouldn't be able to starve other sources.
  • Commands should neither short-circuit their required delay nor incur excessive delays.

Let's see how the code enforces each of these conditions.

Maintaining ordering

Firstly, that commands should execute in order. Say that the following commands arrive, and are assigned between these two queues (the mob's queue and the intraction mode queue).

  1. Mob queue: IC Command #1
  2. Interaction mode queue: OOC Command #2
  3. Mob queue: IC Command #3
  4. Mob queue: IC Command #4
  5. Interaction mode queue: OOC Command #5
  6. Interaction mode queue: OOC Command #6

After they arrive, they'd be arranged in the queues in this order:

Mob queueInteraction mode queue
IC Command #1OOC Command #2
IC Command #3OOC Command #5
IC Command #4OOC Command #6

But of course, if we want them to execute in the right order, they'd need to be staggered a bit, like this:

Mob queueInteraction mode queue
IC Command #1
OOC Command #2
IC Command #3
IC Command #4
OOC Command #5
OOC Command #6

In order to enforce this ordering, we have to control when commands get executed. One way this is done is by having the interaction mode queue give precedence to the mob's queue. See this code:

# in MainInteractionMode
elsif mobIsIdle?()
    if invocation = @invoQueue.dequeue()
        # ...

As I described, the interaction mode doesn't permit new commands to be executed if the mob is busy. Of course, this isn't enough. Given our incoming commands above, this would produce the following (incorrect) ordering, because the mob is busy until it's queue is drained, and only then can the interaction mode do the OOC commands.

Mob queueInteraction mode queue
IC Command #1
IC Command #3
IC Command #4
OOC Command #2
OOC Command #5
OOC Command #6

In order to correct this, we need to have a mechanism for the mob to wait until the interaction mode is done, too. Recall that all commands except direct method calls on mobs pass through the interaction mode in some way. Usually, for IC commands, they pass straight through to the mob with little examination.

# in MainInteractionMode
if invocation.kind_of?(ICInvocation)
    if isIdle?()
        addInvocationForMob(invocation, true)
    else
        addInvocation(DeferredMobIntermodeInvocation.new(invocation))
    end
else

This code says that if the mob is currently idle, we pass commands straight through to the mob, but if it's busy, we actually put a placeholder command in the interaction mode queue, not the mob queue that, when it is executed, will then send the command to the mob. So the interaction mode controls the overall ordering here. Given this technique, the commands would be arranged this way in the queues:

Mob queueInteraction mode queue
IC Command #1
OOC Command #2
Deferred(IC Command #3)
Deferred(IC Command #4)
OOC Command #5
OOC Command #6

The first IC command goes to the mob because it's idle at that time. The rest are managed by the interaction mode queue.

...

What am I doing?

Hang on. Hang on a second. What the hell am I doing, anyway? I'm trying to convince you it was a good idea to take a single queue and split it up into three separate queues (I hadn't even gotten to describing the third one yet), then add in a bunch of extra (complicated, fragile) logic to enforce orderings that make them simulate a single queue. How on earth did I think this was a good idea?

Blogging to the rescue again. As I wrote this, I realized that I hadn't covered some particular interleaving, so I went to add in the code to fix it up. In the course of doing so, I found that one way to solve it was to make the mob add commands back into the interaction mode queue in certain cases. And then I realized that if I did this, I might as well go back to a single queue. So I did. I yanked out basically 1-2 weeks' worth of work splitting the queues as I consolidated them again.

But all is not lost; this post was about a couple things, only the first of which was the splitting of the queues. After that I wanted to talk about new capabilities for calling methods on mobs.

Changes to calling methods on mobs

Early in this post I mentioned that functions executed directly on a mob basically bypass any kind of queuing, delays, etc.. I've now fixed at least some of those problems.

Firstly, it's no longer permitted to call an "action" method directly on a mob, like this:

mob.doLookAt(otherGuy) # not cool

The proper way to do it is (slightly clunky, unfortunately):

mob.doAction(:doLookAt, otherGuy) # A-OK

There's one solid reason for changing this: to funnel all "actions" through a common code path where I can do various types of checking. Mobchecks, verifying delays, and deferring are all things that are done in doAction. Let's take a look at them.

Mobchecks

Mobchecks are a simple way of checking for validity of various conditions when doing a command. It turns out this is a pretty common thing to need to do. Check out this typical function in which I've bolded all of the error handling code that spits out a message when the target is missing or something.

def doWhisper(targetString, whisperedText)
    textError = nil

    if targetString
        if whisperedText
            bagSearchSpace = self.room.bagMobsInRoom()
            doWhisper_Direct(bagSearchSpace.searchByTargetString(targetString, self), whisperedText)
        else
            textError = 'Whisper what to him?'
        end
    else
        textError = 'Whisper to whom?'
    end

    if textError
        seeText(textError)
    end
end # function doWhisper

def doWhisper_Direct(target, whisperedText)
    if target
        evt = WhisperEvento.new(self, target, whisperedText)
        sendEvento(self.room, evt)
    else
        seeText('You don\'t see him here.')
    end
end # function doWhisper_Direct

This type of code has to go into every single function, and moreover, a lot of it is kind of repetitive--checking for valid arguments, targets, etc.. It would be nice to make these kinds of checks easier to perform, since there will be many of them...

def doWhisper(targetString, whisperedText)
    mcheck_arg(targetString, 'Whisper to whom?')
    mcheck_arg(whisperedText, 'Whisper what to them?')

    bagSearchSpace = self.room.bagMobsInRoom()
    doWhisper_Direct(bagSearchSpace.searchByTargetString(targetString, self), whisperedText)
end # function doWhisper

def doWhisper_Direct(target, whisperedText)
    mcheck_targetPerson(target)

    evt = WhisperEvento.new(self, target, whisperedText)
    sendEvento(self.room, evt)
end # function doWhisper_Direct

These "mobchecks" are just a convenient syntax for checking those same conditions. They're pretty simple, and use the throw/catch syntax in Ruby (not to be confused with raise/rescue) to early-out from the function at the point a check fails and using a supplied message.

Here's the machinery that makes it work:

module MobChecks
    # ...

    def mc_errOut(msg)
        throw(MC_ErrOut, msg)
    end # function mc_errOut

    def mcheck_arg(target, msg)
        if target == nil || target == ''
            mc_errOut(msg)
        end
    end # function mcheck_arg

    # ...
end

class Mob
    # ...

    def doAction_Real(symAction, *args)
        if (text = catch (MobChecks::MC_ErrOut) {
                      DebugOutput.debugOut(LComment) { "#{self} doing action: [#{symAction}, #{args.map() { |x| x.to_s() } }]" }

                      send(symAction, *args)
                  }).kind_of?(String)

            seeText(text)
        end
    end # function doAction_Real

    # ...
end

We simply catch the string associated with the error if any, and print out the error message.

Adding Delays

Delays for actions are important. It's how we prevent someone from doing too many things too quickly. It used to be that the delay for an action was embedded in the ICCommand object, but now it's take care of within the mob itself. This is better, because it allows actions regardless of their origins to have delays, and actions that have no associated command at all to have delays.

def doSlay(target)
    mcheck_amPhysical()
    mcheck_targetPerson(target)
    mcheck_personNearby(target)

    mcheck_physicalPresence(target)

    addWaitPulses(10)

    sendEvento(self.room, SlayEvento.new(self, target))
    target.die()
end # function doSlay

Clearly, slaying is something that should give one pause, so that's exactly what we do--10 pulses worth, in fact. For the time being there's also code in doAction_Real that checks to make sure any command has added some delay.

Deferring actions

When the mob is busy in the delay after an action, obviously any new action arriving at doAction shouldn't execute right away; it has to wait for the delay to run out first. We enforce this by deferring the action:

def doAction(symAction, *args)
    if isIdle?()
        doAction_Real(symAction, *args)
    else
        invoQueueInteractionMode().addInvocation(DeferredMobActionInvocation.new(symAction, *args, true))
    end
end # function doAction

As might be obvious, the DeferredMobActionInvocation is simply a wrapper around the action and its arguments. It goes to the end of the queue and executes in its appropriate order.

The new, simpler code also enforces at least two of the three invariants I mentioned earlier. The starvation one isn't completely taken care of, but it's addressed well enough for the most important case, that someone else shouldn't be able to order you nonstop so that you can't react.

So here endeth another lesson where blogging saved my MUD from bad, overcomplicated, what-the-heck-was-I-thinking design. Onwards to combat!

0 comments:

Post a Comment