-
Notifications
You must be signed in to change notification settings - Fork 41
A new phase for bonzAI: update()
One thing that bonzAI has always done rather inefficiently is finding/creating its Operations. Every tick it has to scan through your flags and create new objects. These get used for one tick only. The cost for this process will increase as the number of flags and operation types increase.
There is another drawback to this method. Since your operations/missions only live for one tick, the only way to give them a "history" is to save data to memory.
So what can be done?
I was curious about how things might be different if I started declaring my operations/missions at the global scope so they could live longer than a single tick. I decided to accomplish this by adding a new phase. Really, I just broke up the current init
phase into two functions:
-
init()
- Runs once per the lifetime of an object -
update()
- Runs every tick
Most of my missions/operations could be converted to this scheme by simply renaming the current init
function as update
and then adding a new init
function (yes, this probably seems silly, lets not go into that). I also had to do some tinkering in the main loop to support this behavior, and I added a lot of new constructs like OperationFactory. In fact, I got rid of loopHelper.ts. There is enough different there to deserve its own article, which I'll write before I actually merge this code to the master branch.
I ran into some snags, for sure, see the next section.
Any object that gets instantiated outside your main loop can live for multiple ticks. Here's the tricky part, since your object environment is supported by multiple nodes, most of the time you will not get the same object for multiple ticks.
My naive understanding of global when I first started using it was this: An object gets created in global scope on tick 0. That object is now "live" on ticks 0, 1, 2, 3, 4, etc. Hooray! Simple caching and intuitive behavior.
Unfortunately my parade was quickly rained upon. That isn't how it will work, when multiple nodes support your object environment. Here is a more typical scenario:
Node 1: 0 2 7
Node 2: 1 3 9
Node 3: 4 6
Node 4: 5 8
So operation foo
on node 1 will get created on tick 0 and be live for ticks 0, 2, 7. The same operation will get created on node 3 on tick 4 and be live for ticks 4, 6.
So if you work this way, it is important to have an accurate understanding of the "lifetime" of each of your objects. Any data that you cache on an object needs to be something that is fixed or very unlikely to change. For example, you'll get into some funny behavior if the four versions of your foo
operation disagree on where a creep should travel.
You still have operation.memory
and mission.memory
which work the way they always have, and are still useful for providing a reliable "history" for any operation/mission.
All gameobjects, Creep, Room, etc. really are only intended to be used on the tick that they are created. You can cache a creep on tick 5, and that reference will remain valid, but it will always refer to the creep that existed on tick 5. A new creep object with the same soul was created on all subsequent ticks and those are the objects that you ought to be using.
Other problems can come up from caching short-lived objects. We could assign operation.sources
each tick, and that would work as expected 99% of the time. But what if you lose vision in that room? Unless you now explicitly assign undefined
to operation.sources
, it will continue to hold a reference to the sources that existed on a previous tick.
To solve this problem, I started using an operation.state
and mission.state
object. A new state object is guaranteed each tick. So if you save a value to mission.state.foo
on tick 5 and not on tick 6, you can count on mission.state.foo
to be undefined on tick 6.
While I was making such a huge overhaul, I decided to change something that had been bugging me. I decided to make all the phase functions have the same identifiers, on the operation and mission level. So instead of operation.init()
and mission.initMission()
, it is now just mission.init()
. This means there will be some arbitrary renaming ahead.
For the most part, I started out with the process I described above. I renamed the init
phase to update
and created a new init
phase. Letting it stay this way will have behavior similar to what it was before, the code will run each tick.
There may be some functions that you want to move to the init
function, so that they only run once per the life of the object. Stuff like igorPosition
, upgraderPositions
are great to put here. I would advise a bit of caution though, this is not a good place to put CPU-heavy functions with the rationale that it is good to only run them once. Most of the time, this will run during global refresh phases which already have to do a lot of heavy lifting by parsing your code. Adding a lot of extra overhead to these ticks will cause spikes and possibly timeouts.
The vast majority of the updates to each mission were just assigning the short-lived objects to mission.state
. I started defining an interface for mission.memory
and mission.state
in the abstract class. This is nice because any
doesn't give you any type help, and it is easy to run into bugs. The downside is it adds a bit of extra work for setting up each mission. I now declare something like this in each mission header:
interface MiningState extends MissionState {
container: StructureContainer;
source: Source;
storage: StructureStorage;
}
interface MiningMemory extends MissionMemory {
potencyPerMiner: number;
positionsAvailable: number;
transportAnalysis: TransportAnalysis;
distanceToStorage: number;
roadRepairIds: string[];
prespawn: number;
positionCount: number;
spawnDistance: number;
}
Within MiningMission I now have these two fields:
export class MiningMission extends Mission {
public state: MiningState;
public memory: MiningMemory;
// ...
}
Converting a mission is fairly simple if you do something like this, and remove the fields in the class itself for the object in question. For example, if I remove miningMission.container
and add it to MiningState, my IDE will now complain about each miningMission.container
reference. I just have to iterate through these (F2 on webstorm) and change the reference to miningMission.state.container
. This is a pretty brainless task, I recommend doing it with a light beer-buzz and some metallica playing.
First, the CPU drop was pretty dramatic, at least by my standards. I immediately saw a 10% - 15% drop. Most of this was in the init()
phase, which went from about 50-60 cpu per tick to 30-40. This is just because it doesn't have to create thousands of missions/operations each tick anymore.
Second, the fact that your objects can now live longer than one tick opens the door for a lot of creativity. For example, in my BaseRepairMission I have a function that figures out which towers should participate in the repair based on the coordinates of the object being repaired. Since this return value is not going to change very often, I can memoize the function fairly painlessly. I put this in my init()
function:
this.towerSearch = _.memoize(this.towerSearch);
And works exactly as you'd expect! On each global refresh I see this function has a cpu cost of around .2, and on all other ticks I see it has a cpu cost of around .002. Pretty neat!
You now also have an easy way to cache much larger objects, like costmatrices. It was prohibitive to cache these on the mission/operation level before just due to the memory footprint. Now, go ahead and save them to a field in your operation/mission, no problemo!
The major issue, bonzAI just got more complicated. An extra phase, and a pretty decent chance for unintuitive behavior in each mission due to how the nodes support objects. It is up to you to decide if that is worth it. When I'm done testing this code, I plan to immortalize the current code in the master branch as version 2. This new code will become version 3.