I'll let you in on a little secret. I've been working on combat lately. I guess that's not a really great secret, but whatever. I have a roadmap of several features that need to be implemented to have the basic breadth of it working, which will involve simple fights against nipics, and a bit of AI that will make the player work a bit during one of those fights.
One of the most basic features of this combat is that a nipic, if attacked, should retaliate. Seems simple, right? Actually, making this work and look right took me down a rabbit hole of problems with my evento system. It seems quite a while ago, I solved one evento problem, but I recently found that that wasn't the whole story.
No death
The first problem occurred when I attacked a mob enough times that it died. Ideally this would be accompanied by a message about his untimely death, but I found that this was being swallowed somehow, and not shown. The problem turned out to be that upon losing enough HP so as to render him dead, the mob would try to send the message after tearing down his invocation queue, manager, etc.--that is, the machinery required to send that evento out. To fix this I had to make it so that non-IC invocations are allowed to run just before tearing down everything. OOC invocations, by definition not really able to affect the game world, are permitted upon death.
The way this was actually implemented is a bit clever. Now, when you call Nipic#die()
, we just start the dying process. First we set our state to "dead." Then we flush all IC invocations from the queue of pending actions (known as the invocation queue). Then we send the DeathEvento
. Setting our state to "dead" as early as possible ensures that any actions we try to do will not be permitted (due to being, you know, dead). Flushing IC invocations ensures that nothing the nipic already had waiting will occur.
Then we simply catch the DeathEvento
in the usual course of handling messages and finish tearing down at that point. And of course, we since we stop processing any more invocations at death, any subsequent actions will also not be reached.
An early grave
The only problem with this turned out to be that you'd see things in the reverse order, much like I'd solved before. You'd hit the mob, but the death message would be displayed immediately preceding the message that you'd hit him.
The problem--which will turn out to be something of a theme--had to do with inline handling. The code for hitting someone looked something like this:
def doHit(target)
target.loseHP(10)
sendEvento(HitEvento.new())
end
def loseHP(amt)
self.hp = hp() - amt
if hp() < 0
die()
end
end
def die()
sendEvento(DeathEvento.new())
end
So there are two eventos that get sent here. Because the DeathEvento
is being sent at the time the loseHP
call is made, that effectively puts it before the HitEvento
sent by doHit
.
So let's switch the order in doHit
. That should fix it, right?
def doHit(target)
sendEvento(HitEvento.new())
target.loseHP(10)
end
Well, I didn't show you the whole picture. Remember that a nipic will retaliate when hit.
# think "onHit". aee stands for "after experience evento"
def aeeHit(evento)
if (evento.kind_of?(HitEvento) && evento.target == self)
doHit(evento.source)
end
end
It is useful to see the stack at this doHit
call. Note that the stack is growing upwards.
source.die()
source.loseHP()
target.doHit(source)
target.aeeHit()
target.receiveEvento(HitEvento)
source.sendEvento(HitEvento)
source.doHit(target)
What's wrong with this picture? What's wrong is that the target was effectively able to catch the HitEvento
and retaliate, killing the source when in fact he himself should have been killed by the hit. So both orderings don't work properly. What we want is this order:
- source sends
HitEvento
- everyone in the room sees
HitEvento
- target loses HP, potentially dying
- target sends
DeathEvento
. everyone sees it
The way I tackled this is by not doing the loseHP
call in doHit
but rather in the aeeHit
handler. At this point, I'd hope that everyone has seen the HitEvento
, so any replies will show up correctly after it.
Replying In-line
That last sentence from the previous section? Totally not true. It was an assumption I had made, but which my code wasn't sticking to. I'd tried to fix it before, but obviously not well enough. The problem stems (here's the theme again) from sending eventos in-line with receiving them. When I say "in-line" I mean later/lower in the stack. For instance, before I fixed up the code, here's a stack you might see. I'll just embellish the stack from before.
room.forwardEvento(each occupant)
room.receiveEvento(HitEvento)
source.sendEvento(HitEvento)
source.die()
source.loseHP()
target.doHit(source)
target.aeeHit()
target.receiveEvento(HitEvento)
room.forwardEvento(each occupant)
room.receiveEvento(HitEvento)
source.sendEvento(HitEvento)
source.doHit(target)
The interesting parts are in blue. When you send an evento to your current room, it gets sent, one at a time, to each occupant. This means that higher up on the stack when the room hasn't delivered the HitEvento
to the second half of the room, it's now already delivering a later evento, the DeathEvento
to everyone in the room. Half the people will see it in the correct order; half will see it in the reverse order.
With such a flaw, I kind of need to justify reentrant code of this sort. The problem is, I can't really. It seemed like a good idea to deliver eventos as quickly as possible, but maybe it will cause (more) serious complications later. I'm not sure. It's a "known unknown".
How would/did I fix this? By deferring eventos until the next pulse. Either unconditionally (not what I did) or conditionally (what I ended up doing), I can put an action into either the sender or the receiver's invocation queue to deliver the evento on the next pulse. That eliminates the reentrancy and therefore the incorrect ordering.
I mentioned that I do this deferring conditionally. The conditions are based on states that a mob can be in:
- idle, and receiving an evento
- idle, and sending an evento
- in the middle of receiving an evento, and want to send an evento
- in the middle of sending an evento, and someone else is sending you one
- in the middle of sending an evento, and need to send another one
- in the middle of receiving an evento, and receiving another one
Actually, not all of these are possible. I'll explain each in turn.
Sending while idle, receiving while idle
These are the roots of whatever problems this system has. If a mob sends an evento while it is idle, and the receiver of the evento is likewise idle, then the evento is delivered immediately right on that stack.
Sending while already receiving
This is probably the next most common case. When a mob receives an evento and wants to respond to it, it does a sendEvento
. Since it's already receiving an evento, if it responded immediately, it would run into the problems I discussed earlier, so it defers the sendEvento
call until the next pulse.
Receiving an evento while someone else sends you one
To be honest, I'm not too sure this is actually possible, so I put an assert in the code to let me know if it happens.
Sending an evento while already sending one
This situation seems unlikely, but is possible due to an optimization I allow. It's very common for a mob to send an evento to the room that it's in, which means it will end up being sent to all occupants, including back to itself. With respect to ordering, there's no need to defer the delivery; intuitively the second evento has to be occurring after the first evento it's acting in response to.
So this arises when an evento sent by some mob is handled and acted on by the mob itself.
Receiving an evento while already receiving one
This is another one I'm not sure is possible, so there is an assert for it.
Slowness
All this deferring is making me slow, or so I felt (without any strong evidence to back it up). So I made some modifications to the invocation queue to make these evento changes less noticeable.
The first change was allowing multiple invocations to run on a single pulse. Previously it only allowed a single invocation to run, but now it will run invocations until the queue is empty or until there is some delay. The SendEventoInvocation
and ReceiveEventoInvocation
invocations both have a delay of 0 pulses, so they will be run without really getting in the way of any other eventos.
The second change I made was a refinement of the first change. It allows certain invocations to run even if the mob is currently busy (*gasp*). As you can probably guess, these invocations tend to fall into the category of evento delivery invocations, but I wrote the code so that it could expand or change without too much reworking.
while (invocation = peekNextInvocation()) && canRunInvocationNow?(invocation)
dequeueNextInvocation()
# ...
runInvocation(invocation)
end
def canRunInvocationNow?(invocation)
return (!inDelayState?()) || canRunInvocationWhileBusy?(invocation)
end # function canRunInvocationNow?
What I like about this code is that the functions are phrased like the logical question to ask at the given time. When checking if the loop to process invocations can continue and run the current one, we ask, "can I run this invocation now?" And that in turn asks its constituent questions. Only at the deepest level do we query the actual state of the object.
And with that, the state of eventing is much more reasonable now. I think it may be in a state where I can push forward towards the rest of combat. I feel like the basics are in place.