Writing an AI script for Age of Empires III (Part II)

Here you can post about your scenario's, mods, custom maps and YouTube channels!
Madagascar AlistairJah
Crossbow
Posts: 14
Joined: May 6, 2018
ESO: Aliwest
Location: Madagascar

Writing an AI script for Age of Empires III (Part II)

  • Quote

Post by AlistairJah »

Go to Part I
Go to Part III
Download the reference

Note: due to the character limit in ESOC forums, I had to split the contents of the tutorial :( I'm sorry for making you go from one page to another :D

INTRODUCTION
So, you have some basic programming knowledge and experience now, nice! In this part, we are going to look at the basic concepts of AI scripting.



THE BASIC CONCEPTS OF AI


I. IDENTIFIERS
In an AI script, a considerable amount of things are identified by a number. The main reason is that it is considerably simpler to work with numbers than with texts.

1. Game Data Constants
Explaining some basic concepts of modding in this tutorial would take too much time and is not particularly useful, but basically, an item of a gameplay element gets an ID number according to the order in which it is defined. For example:
• "civilization" is a gameplay element, and each civilization in the game (Spanish, Iroquois, Japanese, etc.) is an item of that gameplay element. The first civilization that is defined in the list of civilizations is Nature, so it gets the ID 0. The second is Spanish, so Spanish is 1. The third is British, so it's 2 etc.
• "technology" is another gameplay element, and each tech in the game (Hunting Dogs, Hanami Parties, etc.) is an item of that gameplay element. The first tech in the list of techs is Age0French (it's basically a tech that enables the techs that are accessible to the French civilization), so its ID is 0.
Well, I guess you get it now, even just vaguely. You can learn more by finding a modding tutorial for Age of Empires III.

Knowing that there are potentially thousands of items in the game (certain mods even add more items), it's honestly discouraging to have to remember all these IDs. Fortunately, the game is helping us by storing those IDs in constants. You already know what a constant is, I'm not going to explain it again. The reason why they are stored in constants is that, unless you modify the game (i.e. you're modding it, no kidding), these IDs never change. If you enabled the developer mode and added the logAIErrors entry, a .dmp.txt file should be generated in User folder\AI3 each time you play a Single Player Skirmish. That file contains the list of constants, as well as other things but we'll talk about those later.

2. In-game ID
The ID of certain items change from a Skirmish to another. In the Script side, for example, the same array can have a different ID from a Skirmish to another (that depends on the way you wrote it though, but still, it can be different). In the Game side, even if you keep playing the same civilization on the same map using exactly the same configuration (number of players, their civilizations, game mode, etc.) in every single game you play, the IDs of every object on the map (Explorer, Villagers, trees, travois, etc.) will change from a game to another.

I will not elaborate too much. I just want you to know that from now on, we are going to work with a lot of ID everywhere. O yes, little do you know.

3. In-game Constants
Their values depend on the configurations made in the pre-game menu. Some of them have different values for each player.
• cNumberPlayers (const int): stores the number of players in the current match plus Mother Nature (so, for example, in a 1v1, cNumberPlayers == 3)
• cMyID (const int): stores the Player ID of the context player (see below)
• cMyTeam (const int): stores the ID of the context player's team
• cMyCiv (const int): stores the civilization ID of the context player
• cMyCulture (const int): stores the culture ID of the context player.
• cRandomMapName (const string): stores the string filename of the current random map (without file extension). The value is "None" in scenarios.


II. CONTEXT PLAYER

The context player of an AI script is the AI player that is controlled by the script. Imagine, for example, that we have an AI script for the French civilization, i.e. it controls the AI player Napoléon: it means that the context player of the script is Napoléon. The ID of Napoléon can change in different matches: sometimes he is the Player 1, sometimes he is the Player 2, etc. It's important to understand this concept of context player because, unless the AI script has been written very specifically for a fixed player in a particular scenario, you can never really predict what ID is the AI player going to have.

If the same AI script is controlling different AI players in the same game, the script is instanced so that each AI player gets a unique instance of the it. The perfect example is the AI script aiMain.xs (in Game Folder\AI3): it is loaded by all of the 14 AI players in the game (Queen Isabella, Queen Elizabeth, Napoléon, etc.) Each time you play with AI players, the game creates an instance of the script for each of them. That's why, again, it's important to know about the concept of context player.

In an AI script, the Player ID of the context player is stored in the constant cMyID, and you can also use the function xsGetContextPlayer (no parameter) which returns the Player ID of the context player.

When we use the debugger (we'll do that soon), i.e. we want to carefully and meticulously observe the behavior of an AI player (implied: we want to test how the AI script controls the AI player), we must put ourselves in the AI player's context. There are two ways to do that:
• record a game with an AI and debug in Help & Tools>Recorded Games
• set up a scenario and put ourselves in an AI player's context (like we did in Part I)

For this tutorial, we're going to do the latter since it's much faster. You already know how to load an AI script in a scenario and I assume that you also know how to use the scenario editor. We're actually going to create another script and another scenario in the next section.


III. TERRAIN ANALYSIS

Quoting Captn_Kidd, an AI scripter of Age of Mythology (Age of Empires III is using the same game engine as Age of Mythology): "When you are writing an AI script for AOE3, sometimes you will have to control some sophisticated low-level support systems. One of them is the terrain analysis, which consists of dividing the terrain into small areas in such a way that the contiguous areas of similar terrain are put together to form one area. For example, a forest is an area by itself, open ground is broken into areas between obstructions, etc." Don't worry, the game will do the majority of the job for you, all you have to do is to call the function kbAreaCalculate before anything else:

Code: Select all


void main( void )
{
kbAreaCalculate();
}
As soon as you want to write more advanced AI routines (such as gathering, scouting, etc.), it becomes mandatory to call that function. An AI script can refer to areas by their ID numbers. When we are debugging, it is possible to visualize each area. Let's do that, and my long explanation earlier will make much more sense.

• open your text editor
• create a new file
• write these lines:

Code: Select all


void main( void )
{
kbAreaCalculate();
}
• save as Game Folder\AI3\_AIScriptTutorial.xs (put an underscore in the beginning so the file appears first in the list in-game)
• run The Asian Dynasties
• go to Help & Tools>Scenario Editor
• go to File>New
• choose a map. I have chosen New England but you can choose any map (except uimappicker2)
• change the other options if you want, it doesn't matter right now
• click on the button Generate
• go to Scenario>Player Data
• in the section of Player 1, change Human to Computer
• click on the button AI
• find and select our AI script _AIScriptTutorial.xs
• click on the button Load
if you get an error or an empty dialog box, simply repeat the process from File>New etc.
• change the other options if you want (I changed the name), but make sure that Player 1 is Spanish
• close the Player Data dialog
• save as AITutorial.age3Yscn
• in the main menu, go to Single Player>Custom Scenario
• find and select the testing scenario AITutorial
• click on the button Load
• normally, you're on Player 1's viewpoint. If that's not the case (perhaps you edited the scenario before saving), get back to the editor, load the scenario, put yourself in Player 1's viewpoint and save
• display the console by pressing Alt-E
• enter the command blackmap to stop rendering the black map (the black map is still there but not rendered)
• enter the command fog to stop rendering the fog of war (the a fog of war is still there but not rendered)
• dismiss the console by pressing Alt-E
• display the debugger by pressing Alt-Q
• dismiss the big blue debug output window on the bottom screen because it's bugged, it doesn't work
• visualize each area group first to have a better idea of where an area is: click on AreaGrps, choose a group and find the colored parts on the terrain. A small blue window appears, it contains the ID of each area in the selected group. When you want to view another group, dismiss the small blue window first.
• visualize an individual area by clicking on Areas and select and ID (you may have found it in the area groups first)

A few things we can notice:
• all areas in the same group are connected, i.e. a land unit can go from area A to area B without transport (a ship or a flying unit in certain mods) if these areas belong to the same area group
• even if an object seems to be on two different areas, it actually belongs to only one of them
• trade routes, though they are split into different areas, have their own areas, i.e. they never share the same area as the terrain around them
• the zones outside the terrain (the black ones) are also in areas and area groups. The entire map is, in reality, a square, not a circle. It's just not possible to go beyond the circle.

Later on, we will see how to work with areas in a script.


IV. PLAYER BASES

The AI identifies the "town" or "village" of each player by a base. A base has the following characteristics:
• an ID: each base has its own unique ID to differenciate them from each other.
• an owner: a base must belong to a player. Mother Nature can't have a base.
• a name: only useful for debugging purpose.
• a center position: a very precise position on the map. Can't be changed once the base exists.
• a radius: a certain distance from the center position, which practically forms a circle. Can't be changed once the base exists.
• a front and a back position: used by the internal AI mostly for building placement. Can be changed in the script.
• a state: an inactive base is ignored for most tasks (gathering, building, etc.) while an active base is useable. Can be changed in the script.
• some flags: indicate the type of the base: main base, economic base, settlement, military base, forward base. Can be changed in the script.
• units: there must be at least one unit in a base, else it gets dismissed by the AI. It is possible to manually assign a unit to a base in the script, but a unit can only belong to one base at a time.

In the debugger, bases are listed in the Bases menu, and the list is updated in real time.

Note that bases serve only as a way to identify structures on the terrain. Creating a base means assigning an identifier to a certain zone on the map because that zone has a building in it, and destroying a base means removing that identifier (NOT destroying the objects in the zone).

The AI automatically creates bases on its own over time, based on its knowledge of the world and without any instruction from the script. However, in the script, we can tell the AI to create or destroy a base, we can even tell the characteristics that the base should have before the AI creates it.

The first base the AI creates in a match is its own base, named AutoBase0. It is centered on the position of the AI's first Town Center, has a radius of 37.5~38 meters (which is pretty much equal to the base LOS of TAD:RE's Town Center) and is an active, main, economic, military and settlement base. Every unit and building that is located in that zone belongs to AutoBase0, but when units walk away, their base can change.
You can test it out: open the scenario and display the debugger, then go to the Bases menu and select AutoBase0. There, you can see detailed informations about the base. Scroll down to view the list of units that belong the base. Move the explorer out of the base: when it's far out, it doesn't belong to the base anymore! If you move it back, it will be part of the base again. It can be a good way to search for a certain unit but we'll see that later.

I don't know what's your opinion on this, but I think that the radius is rather small for a main base. We're going to destroy that base and create a new one with a larger radius (say, 60 meters).

Code: Select all


void initializeMainBase( float radius = 38.0 )
{
/* We are assuming that AutoBase0 is the only base of the context AI player at this point.
Therefore, we can skip the filtering statements that determine the proper base to destroy,
because only one base exists anyway. However, be careful in custom scenarios where the scenario
designers may make multiple and separated groups of buildings which the AI may consider as bases. */

// First of all, memorize the location of the first base:
vector baseLocation = kbBaseGetLocation( cMyID, kbBaseGetMainID( cMyID ) );

// Now, destroy the base:
kbBaseDestroyAll( cMyID );

// And create a new one:
int newBaseID = kbBaseCreate( cMyID, "My BIG Main Base", baseLocation, radius );
kbBaseSetMain( cMyID, newBaseID, true ); // Make it the main base
kbBaseSetMilitary( cMyID, newBaseID, true ); // Make it military, so the AI considers it in certain military tasks
kbBaseSetEconomy( cMyID, newBaseID, true ); // Make it economic, so tha AI considers it in certain economic tasks
kbBaseSetActive( cMyID, newBaseID, true ); // Make it active, or else it's useless

kbSetTownLocation( baseLocation ); // Sets the location of the main town to be right where the base's center is
}


void main( void )
{
kbAreaCalculate(); // From now on, never forget to call this function
initializeMainBase( 60.0 ); // Destroy AutoBase0 and create a new base with a wider radius
}
If you test it now, you will see "My BIG Main Base" in the Bases menu. However, by testing, you may notice that something isn't quite right: not all units belong to the base, and the AI made another AutoBase with the TownCenter in it! That is because we didn't assign the Town Center to the new base. How can we do that? We need a Unit Query.

Note: if you didn't get that issue while testing, it was a pure luck and even if you keep testing with different maps and players without getting that issue a single time, you're still just lucky (very lucky). Do things correctly, add the TownCenter to the new base.


V. UNIT QUERIES

We use Unit Query to search for units on the terrain. Each unit has its own unique ID which a query function returns. Here's how to use a query:

1. Defining a query
First of all, we define the query:

Code: Select all

int queryID = kbUnitQueryCreate( "Name of the query" )
The name must be unique.

This function creates a query and returns the ID of that query. We store that ID in an int variable (here, it is queryID but you can name it the way you want).

2. Clearing the query
After defining the query, we must clear it so it "forgets" everything it knows about a previous research (if it has been used previously for finding objects):

Code: Select all

kbUnitQueryResetResults( queryID );
Do it even if the query is going to be used only once.

3. Setting query data
After defining and clearing the query, we add "filters":

Object type

Code: Select all

kbUnitQuerySetUnitType( queryID, unitTypeID );
unitTypeID is the object we want to find.
Example: we want to find a Market:

Code: Select all

kbUnitQuerySetUnitType( queryID, cUnitTypeMarket );
Object owner
• A specific player:

Code: Select all

kbUnitQuerySetPlayerRelation( queryID, -1 );
kbUnitQuerySetPlayerID( queryID, playerID, false );
playerID is the owner of the object we want to find.
Example: we want to find our own Market:

Code: Select all

kbUnitQuerySetUnitType( queryID, cUnitTypeMarket );
kbUnitQuerySetPlayerRelation( queryID, -1 );
kbUnitQuerySetPlayerID( queryID, cMyID, false );
• An ally:

Code: Select all

kbUnitQuerySetPlayerID( queryID, -1, false );
kbUnitQuerySetPlayerRelation( queryID, cPlayerRelationAlly );
cPlayerRelationAlly includes the AI itself and all of its allies.

• An enemy:

Code: Select all

kbUnitQuerySetPlayerID( queryID, -1, false );
kbUnitQuerySetPlayerRelation( queryID, cPlayerRelationEnemy );
cPlayerRelationEnemy includes Mother Nature and all enemies.

• An enemy except Mother Nature:

Code: Select all

kbUnitQuerySetPlayerID( queryID, -1, false );
kbUnitQuerySetPlayerRelation( queryID, cPlayerRelationEnemyNotGaia );
lol, now that I'm talking about it, I realize that Mother Nature is the enemy of all players except herself. After two years of AI scripting... smh

Object state
• An alive object:
kbUnitQuerySetState( queryID, cUnitStateAlive );
• An object under construction:
kbUnitQuerySetState( queryID, cUnitStateBuilding );
• A dead object (yes, the AI can see dead objects):
kbUnitQuerySetState( queryID, cUnitStateDead );
• Any state:
kbUnitQuerySetState( queryID, cUnitStateAny );

Object position

Code: Select all

kbUnitQuerySetPosition( queryID, positionVector );
kbUnitQuerySetMaximumDistance( queryID, distanceFromPositionVector );
kbUnitQuerySetAscendingSort( queryID, boolean );
positionVector is, well, a vector variable and distanceFromPositionVector is a float value (can be a variable or a constant).
If boolean is true, the research results will be sorted so that the first element of the results is the one that is the closest to positionVector, the second is a bit farther and so on, the last is the farthest from positionVector. If it's false, the results will not be sorted.
By adding these lines, you're restricting the query to a certain zone instead of the entire terrain.

There are more filters but I deliberately ignored them (for now).

4. Querying
Once the filters are added, we can finally start the query and get the results:

Code: Select all

kbUnitQueryExecute( queryID );
int objectID = kbUnitQueryGetResult( queryID, index );
kbUnitQueryExecute finds all objects that correspond to the filters and makes an array of these objects, then returns the number of objects it found (which you can eventually store in a variable, if you need it). kbUnitQueryGetResult gets the element of that array in the specified index, and returns it. objectID stores the returned value.

Let's see a full example: we are going to find the Town Center in the scenario:

Code: Select all


void main( void )
{
int town_query = kbUnitQueryCreate("Let's find that TC");
kbUnitQueryResetResults( town_query );
kbUnitQuerySetUnitType( town_query, cUnitTypeTownCenter );
kbUnitQuerySetPlayerRelation( town_query, -1 );
kbUnitQuerySetPlayerID( town_query, cMyID, false );
kbUnitQuerySetState( town_query, cUnitStateAlive );
kbUnitQueryExecute( town_query );
int marketID = kbUnitQueryGetResult( town_query, 0 );
aiChat( 1, "The Town Center's ID is "+marketID );
}
You may be wondering, what does kbUnitQueryGetResult return if no unit is found?
Well, it returns -1. All ID start from 0 and go up the more elements there are. Any negative value can mean "not found", "invalid", "unknown", etc.

Now we can update the script to assign the all units to the new base!

Code: Select all


int findUnit( int unit_type = -1, int owner = cMyID, int index = 0 )
{
static int unit_query = -1;
if ( unit_query == -1 )
{
unit_query = kbUnitQueryCreate( "AlistairLovesWritingScripts" );
kbUnitQuerySetState( unit_query, cUnitStateAlive ); // Search only for Alive units
kbUnitQuerySetIgnoreKnockedOutUnits( unit_query, true ); // Ignore "downed" explorers since they're useless yet they count as Alive
}

if ( index == 0 )
{
kbUnitQueryResetResults( unit_query );
if ( owner <= 12 )
{
kbUnitQuerySetPlayerRelation( unit_query, -1 );
kbUnitQuerySetPlayerID( unit_query, owner, false );
}
else
{
kbUnitQuerySetPlayerID( unit_query, -1, false );
kbUnitQuerySetPlayerRelation( unit_query, owner );
}
kbUnitQuerySetUnitType( unit_query, unit_type );
kbUnitQueryExecute( unit_query );
}

return( kbUnitQueryGetResult( unit_query, index ) );
}

void initializeMainBase( float radius = 38.0 )
{
/* We are assuming that AutoBase0 is the only base of the context AI player at this point.
Therefore, we can skip the filtering statements that determine the proper base to destroy,
because only one base exists anyway. However, be careful in custom scenarios where the scenario
designers may make multiple and separated groups of buildings which the AI may consider as bases. */

// First of all, memorize the location of the first base:
vector baseLocation = kbBaseGetLocation( cMyID, kbBaseGetMainID( cMyID ) );

// Now, destroy the base:
kbBaseDestroyAll( cMyID );

// And create a new one:
int newBaseID = kbBaseCreate( cMyID, "My BIG Main Base", baseLocation, radius );
kbBaseSetMain( cMyID, newBaseID, true ); // Make it the main base
kbBaseSetMilitary( cMyID, newBaseID, true ); // Make it military, so the AI considers it in certain military tasks
kbBaseSetEconomy( cMyID, newBaseID, true ); // Make it economic, so tha AI considers it in certain economic tasks

// Assign all of our starting units to the new base:
// kbUnitCount( cMyID, cUnitTypeAll, cUnitStateAlive ) returns the total number of units and buildings the AI has
for( unitIndex = 0; < kbUnitCount( cMyID, cUnitTypeAll, cUnitStateAlive ) )
{
int unit = findUnit( cUnitTypeAll, cMyID, unitIndex );
kbBaseAddUnit( cMyID, newBaseID, unit );
}

kbBaseSetActive( cMyID, newBaseID, true ); // Make it active, or else it's useless

kbSetTownLocation( baseLocation ); // Sets the location of the main town to be right where the base's center is
}


void main( void )
{
kbAreaCalculate(); // From now on, never forget to call this function
initializeMainBase( 60.0 ); // Destroy AutoBase0 and create a new base with a wider radius
}
At that's it, the issue is fixed!

Guide for searching the code name of a unit type

VI. AI PLANS, A MUST

Most of what the AI does with a unit/building or a group of units/buildings is a plan: researching techs, training units, making buildings, gathering resources, etc.

Learn defining plans by looking at the following examples:

(as always, try your own things after testing the examples in this tutorial, that's the best way to get better at AI scripting)

Code: Select all


int findUnit( int unit_type = -1, int owner = cMyID, int index = 0 )
{
static int unit_query = -1;
if ( unit_query == -1 )
{
unit_query = kbUnitQueryCreate( "AlistairLovesWritingScripts" );
kbUnitQuerySetState( unit_query, cUnitStateAlive ); // Search only for Alive units
kbUnitQuerySetIgnoreKnockedOutUnits( unit_query, true ); // Ignore "downed" explorers since they're useless yet they count as Alive
}

if ( index == 0 )
{
kbUnitQueryResetResults( unit_query );
if ( owner <= 12 )
{
kbUnitQuerySetPlayerRelation( unit_query, -1 );
kbUnitQuerySetPlayerID( unit_query, owner, false );
}
else
{
kbUnitQuerySetPlayerID( unit_query, -1, false );
kbUnitQuerySetPlayerRelation( unit_query, owner );
}
kbUnitQuerySetUnitType( unit_query, unit_type );
kbUnitQueryExecute( unit_query );
}

return( kbUnitQueryGetResult( unit_query, index ) );
}


void initializeMainBase( float radius = 38.0 )
{
/* We are assuming that AutoBase0 is the only base of the context AI player at this point.
Therefore, we can skip the filtering statements that determine the proper base to destroy,
because only one base exists anyway. However, be careful in custom scenarios where the scenario
designers may make multiple and separated groups of buildings which the AI may consider as bases. */

// First of all, memorize the location of the first base:
vector baseLocation = kbBaseGetLocation( cMyID, kbBaseGetMainID( cMyID ) );

// Now, destroy the base:
kbBaseDestroyAll( cMyID );

// And create a new one:
int newBaseID = kbBaseCreate( cMyID, "My BIG Main Base", baseLocation, radius );
kbBaseSetMain( cMyID, newBaseID, true ); // Make it the main base
kbBaseSetMilitary( cMyID, newBaseID, true ); // Make it military, so the AI considers it in certain military tasks
kbBaseSetEconomy( cMyID, newBaseID, true ); // Make it economic, so tha AI considers it in certain economic tasks

// Assign all of our starting units to the new base:
// kbUnitCount( cMyID, cUnitTypeAll, cUnitStateAlive ) returns the total number of units and buildings the AI has
for( unitIndex = 0; < kbUnitCount( cMyID, cUnitTypeAll, cUnitStateAlive ) )
{
int unit = findUnit( cUnitTypeAll, cMyID, unitIndex );
kbBaseAddUnit( cMyID, newBaseID, unit );
}

kbBaseSetActive( cMyID, newBaseID, true ); // Make it active, or else it's useless

kbSetTownLocation( baseLocation ); // Sets the location of the main town to be right where the base's center is
}


void main( void )
{
kbAreaCalculate(); // From now on, never forget to call this function
initializeMainBase( 60.0 ); // Destroy AutoBase0 and create a new base with a wider radius

// Set up an explore plan:
// First of all, we create the plan (here, we're naming it Walking with my dog):
int explore_plan = aiPlanCreate( "Walking with my dog", cPlanExplore );
/* Make it use units with aiPlanAddUnitType.
The three last parameters are: numberNeed, numberWant, numberMax
i.e. the plan must will always try to use "numberNeed" at the very least, if it can
but ideally, it would would "numberWant" and if there is an excess of units, it can
use "numberMax" */
aiPlanAddUnitType( explore_plan, cUnitTypeExplorer, 1, 1, 1);
aiPlanAddUnitType( explore_plan, cUnitTypeWarDog, 1, 1, 1);
/* Set the LOS Multiplier. Here, we set it to 4.0 (meter), which will make
the scout move in such a way that the line of sight on one pass very nearly touches
the LOS from the previous pass. If we set it to 6.0 or higher, some black space would be
left between passes but it would cover ground a bit faster. */
aiPlanSetVariableFloat( explore_plan, cExplorePlanLOSMultiplier, 0, 4.0 );
/* Plan priority is for when several plans want to use the same unit.
Personally, for a simple scout (not Explorer), I put high priority for scouting,
a lower priority for attacks and a low priority for defenses (except in emergency) */
aiPlanSetDesiredPriority( explore_plan, 90 );
// Activate the plan! The explorer and his dog will now explore and reexplore the map.
aiPlanSetActive( explore_plan, true );
}


// Set up a "maintain" plan and exit:
rule maintainSettlers
active
{
// First of all, we create the plan
int maintain_plan = aiPlanCreate( "Maintain 20 Settlers", cPlanTrain );
// Train Settlers:
aiPlanSetVariableInt( maintain_plan, cTrainPlanUnitType, 0, cUnitTypeSettler );
// Keep training until we have 20 Settlers. If a Settler dies and the number goes down, train again until we have 20
aiPlanSetVariableInt( maintain_plan, cTrainPlanNumberToMaintain, 0, 20 );
// Activate the plan!
aiPlanSetActive( maintain_plan, true );

xsDisableSelf();
}


// Send herdable to the Town Center and exit
// Place some Gaia Sheeps on random positions of the scenario if
// you generated it with a map without Gaia livestock
rule herdLivestock
active
{
// No more comment
int herd_plan = aiPlanCreate( "Herding", cPlanHerd );
aiPlanAddUnitType( herd_plan, cUnitTypeHerdable, 0, 50, 50 );
aiPlanSetVariableInt( herd_plan, cHerdPlanBuildingTypeID, 0, cUnitTypeTownCenter );
aiPlanSetActive( herd_plan, true );

xsDisableSelf();
}


// Build houses when we need population slots
rule buildHouse
active
minInterval 5 // Check out every 5 seconds
{
// kbGetPop() returns the amount of pop currently occupied by units and queued units
// kbGetPopCap() returns the maximum amount of pop affordable
// It's like the "X/Y" in the UI below the flag above resources: X = kbGetPop(), Y = kbGetPopCap()
if ( kbGetPop() + 5 <= kbGetPopCap() )
return; // Ignore the rest of the rule if there are more than 5 free space
if ( kbUnitCount( cMyID, cUnitTypeHouseMed, cUnitStateBuilding ) >= 1 )
return; // Ignore the rest of the rule if a house is already under construction
if ( kbUnitCount( cMyID, cUnitTypeHouseMed, cUnitStateABQ ) >= kbGetBuildLimit( cMyID, cUnitTypeHouseMed ) )
return; // Ignore the rest of the rule if the house build limit is reached
if ( aiPlanGetIDByTypeAndVariableType( cPlanBuild, cBuildPlanBuildingTypeID, cUnitTypeHouseMed ) >= 0)
return; // Ignore the rest of the rule if a build plan for building a house already exists

// Create the plan:
int build_plan = aiPlanCreate("BuildHouse", cPlanBuild);
// Make it have a very high priority, so that the builder interrupts its current task to build the house:
aiPlanSetDesiredPriority( build_plan, 100);
// Build a house:
aiPlanSetVariableInt( build_plan, cBuildPlanBuildingTypeID, 0, cUnitTypeHouseMed );
// Build it around the builder:
aiPlanSetVariableBool( build_plan, cBuildPlanInfluenceAtBuilderPosition, 0, true );
// To be precise, build it within 5 meters around the builder:
aiPlanSetVariableFloat( build_plan, cBuildPlanInfluenceBuilderPositionDistance, 0, 5.0 );
// Add +100.0 points to the build location's score if the builder is that close
aiPlanSetVariableFloat( build_plan, cBuildPlanInfluenceBuilderPositionValue, 0, 100.0 );
aiPlanSetVariableFloat( build_plan, cBuildPlanRandomBPValue, 0, 0.99 );
// Build it in the main base:
aiPlanSetBaseID( build_plan, kbBaseGetMainID( cMyID ) );
// Use a settler as builder
aiPlanAddUnitType( build_plan, cUnitTypeSettler, 1, 1, 1);
// Build 30 meters behind the main base:
vector backVector = kbBaseGetBackVector( cMyID, kbBaseGetMainID( cMyID ) );
vector buildLocation = kbBaseGetLocation( cMyID, kbBaseGetMainID( cMyID ) ) + backVector * 30.0;
aiPlanSetVariableVector( build_plan, cBuildPlanInfluencePosition, 0, buildLocation );
// Add 1 point to the build location's score if the location is within 20 meters of that vector.
aiPlanSetVariableFloat( build_plan, cBuildPlanInfluencePositionDistance, 0, 20.0 );
aiPlanSetVariableFloat( build_plan, cBuildPlanInfluencePositionValue, 0, 1.0 );
// Activate the plan! This plan will be destroyed by the AI as soon as it succeeds in building a house
aiPlanSetActive( build_plan, true );
}
You can find each plan in the Plans menu of the debugger. Also, when the AI creates a build plan, the BPs (Building Placements) menu becomes available, which you can select to see the build locations: the red markers are forbidden build locations, the green ones are allowed and the blue ones are the "best" locations. Needless to say that those are all the AI's opinions, the best for us can be completely different. When you acquire enough experience in scripting/programming, you will be able to control the AI so tightly that you can make it build anywhere you want (provided that it's allowed by the game, like, for example, within the treaty radius in treaty games or far enough from an enemy's first Town Center).

If you tested it enough, you may have noticed something: sometimes, the AI stops training Settlers and/or don't build a house even with enough resources (100 Food and/or 100 wood). I hope you didn't use cheat codes or controlled the AI's units yourself, because if you did that, chances are that you completely missed the issue here. It's normal for them to do that, because in fact, the resources of the AI are split in different accounts, also known as Escrows, which are used for each resource-spending task (training, building, researching). Since the resources are split, the resources in each Escrow are always smaller than the total resources. Go to the Escrows menu to see it by yourself. A real time UI update makes the menu a bit buggy, so you might have to pause the game. We will talk about the AI's Economy system in VII. ECONOMY.

I'm not going to explain every single thing on AI Plans because most of the aiPlan functions are extremely straightforward. I'll let you do experiments with the functions in the reference (the download link is on the top of this page). You can ask in the comments if you have questions or remarks.

Oh, a final tip for the fresh rookies: you can also find examples in Game Folder\AI3\aiMain.xs


VII. ECONOMY

Finally! We're on to something more serious.

1. Escrows

I have no idea about the design philosophy of Escrows and why Ensemble Studios made it, but it's a system that allows us to create multiple accounts to store resources. Each resource-spending AI Plan can use an account so that it can't "steal" resources from other accounts.

By default, there are four accounts: Root, Economy, Military and Emergency. All plans that use the Economy or Military account can use all resources that are stored in that account, but can also take some resources from Root. However, all plans that use the Root account can only use Root and can't "steal" from Economy/Military. Any plan that uses Emergency can use all resources from all accounts - for example, the AI of TAD uses the Emergency Escrow for age ups with europeans and natives, and uses the Economy Escrow for age ups with asians.

Creating an Escrow
It is possible to create new escrows:

Code: Select all


// Create an escrow and store its ID in a variable
// Example: this escrow
// * is named "The Tommynator"
// * can store all types of resource
// * is storing 15% of the resources (lol) contained in Root
int myNewEscrow = kbEscrowCreate( "The Tommynator", cAllResources, 0.15, cRootEscrowID );
Ah yeah, about the 3rd item in the list, each escrow can have a "parent" escrow from which it takes resources.

Destroying an Escrow
It is also possible to destroy escrows:

Code: Select all


kbEscrowDestroy( escrowID, true );
The second parameter is a boolean. If the destroyed escrow is the "parent" of another escrow, specifying true will "promote its children", i.e. these escrows will remain and will be independent. Specifying false will destroy all child escrows with the parent.

Weighing an escrow
It is possible to specify how much of the total resources an escrow can store:

Code: Select all


// Doing this in a rule is particularly useful to periodically reconsider the weights
// because the importance of Economic and Military investments changes throughout the game


// We're making Economy Escrow store 20% of all Food, 30% of all Wood and 20% of all Gold
kbEscrowSetPercentage( cEconomyEscrowID, cResourceFood, 0.2 );
kbEscrowSetPercentage( cEconomyEscrowID, cResourceWood, 0.3 );
kbEscrowSetPercentage( cEconomyEscrowID, cResourceGold, 0.2 );
// We're making Military Escrow store 60% of all Food, 50% of all Wood and 60% of all Gold
kbEscrowSetPercentage( cMilitaryEscrowID, cResourceFood, 0.6 );
kbEscrowSetPercentage( cMilitaryEscrowID, cResourceWood, 0.5 );
kbEscrowSetPercentage( cMilitaryEscrowID, cResourceGold, 0.6 );
// We're making Root Escrow store 20% of all Food, 20% of all Wood and 20% of all Gold
kbEscrowSetPercentage( cRootEscrowID, cResourceFood, 0.2 );
kbEscrowSetPercentage( cRootEscrowID, cResourceWood, 0.2 );
kbEscrowSetPercentage( cRootEscrowID, cResourceGold, 0.2 );
// Finally, we're reallocating the current resource stockpile into these escrows:
kbEscrowAllocateCurrentResources();
The game automatically normalizes the values so they sum up to 1.0 (100%). It is impossible to alter the Emergency Escrow in any way.

With that knowledge, you can try to fix our issue in VI. AI PLANS, A MUST (temporarily, because we're now going to make the AI's Settlers gather resources).

2. Gathering

After properly setting up the Escrow system of your liking, you can proceed to the next step of Economy that is resource gathering.

Gatherer allocation
Allocating gatherers means specifying the percentage of gatherers that will gather each resource type. Example: 70% of gatherers will gather Food, 20% will gather Wood and 10% will gather Gold.

There are two different systems that we can use to control it:
• script-driven system: cRGPScript. With this system, we set the gatherer percentages in the script.
• cost-driven system: cRGPCost. With this system, the AI calculates the gatherer percentages by itself based on the "cost" of a resource (example: Food is costly when the AI needs a lot of units that cost Food while it has very few Food in all Escrows).

We can use these systems at the same time, and assign a weight for each of them so that the heavier system is considered a bit more by the AI when it has to decide. For this tutorial, we'll only use the script-driven system but you can experiment anything you want later:

Code: Select all


aiSetResourceGathererPercentageWeight(cRGPScript, 1.0); // 100%
aiSetResourceGathererPercentageWeight(cRGPCost, 0.0); // 0%
aiNormalizeResourceGathererPercentageWeights();
After that, we can finally set the gatherer percentages:

Code: Select all


// 70% of the Settlers will gather Food:
aiSetResourceGathererPercentage(cResourceFood, 0.7, false, cRGPScript);
// 20% Wood
aiSetResourceGathererPercentage(cResourceWood, 0.2, false, cRGPScript);
// and 10% Gold
aiSetResourceGathererPercentage(cResourceGold, 0.1, false, cRGPScript);
aiNormalizeResourceGathererPercentages(cRGPScript);
Gatherer tasking
Once those are set up, we can finally make the gatherers work. For that, we use the following function:

Code: Select all


aiSetResourceBreakdown( gatherableResourceType, resourceSubType, numberOfGatherPlans, priorityOfThePlans, percentageOfGatherers, baseID);
gatherableResourceType:
• cResourceGold
• cResourceWood
• cResourceFood
• cResourceFame (gatherable in certain mods)
• cResourceXP (gatherable in certain mods)
• cResourceTrade (the Export resource, gatherable in certain mods)

resourceSubType:
• cAIResourceSubTypeEasy - non-fish resources that don't need to be killed (Berry Bush, Mill, Plantation, etc). Tree is an exception (trees are killed when villagers chop them)
• cAIResourceSubTypeHunt - pretty much all resources with the <UnitType>Huntable</UnitType> tag in proto[x|y].xml

(All other subtypes in the reference are not working, I tried)

numberOfGatherPlans:
It's an integer number. The AI will create gather plans ( something like aiPlanCreate("GatherPlan", cPlanGather) if it was written in the script, but it's not written there, the AI will do it internally ) and try to maintain the number of gather plans equal to this parameter. If there's an excess, the AI will destroy the least useful plan. The AI can perfectly make the difference between a plan that is created by the script and a plan that the AI created itself, so don't worry, it will not destroy your plans.

priorityOfThePlans:
It's just like the aiPlanSetDesiredPriority line when we create a plan in the script. Yes, nothing more.

percentageOfGatherers:
Uhm... OK... So... Imagine that 50% of ALL villagers are Food Gatherers. This parameter is the percentage of Food Gatherers that will gather the specified resourceSubType. I can't explain it better, so if you're not sure, just write 1.0 (100%).

baseID:
Gatherers will gather the resources around this base and will not stray too far away from it. "Too far as in how far?" you ask. If I recall correctly, the default distance is 100.0 meters. You can set a different distance using this function:

Code: Select all


// distance is a float value
kbBaseSetMaximumResourceDistance( cMyID, baseID, distance );
All right, with all that, let's see the full example:

Code: Select all


int findUnit( int unit_type = -1, int owner = cMyID, int index = 0 )
{
static int unit_query = -1;
if ( unit_query == -1 )
{
unit_query = kbUnitQueryCreate( "AlistairLovesWritingScripts" );
kbUnitQuerySetState( unit_query, cUnitStateAlive ); // Search only for Alive units
kbUnitQuerySetIgnoreKnockedOutUnits( unit_query, true ); // Ignore "downed" explorers since they're useless yet they count as Alive
}

if ( index == 0 )
{
kbUnitQueryResetResults( unit_query );
if ( owner <= 12 )
{
kbUnitQuerySetPlayerRelation( unit_query, -1 );
kbUnitQuerySetPlayerID( unit_query, owner, false );
}
else
{
kbUnitQuerySetPlayerID( unit_query, -1, false );
kbUnitQuerySetPlayerRelation( unit_query, owner );
}
kbUnitQuerySetUnitType( unit_query, unit_type );
kbUnitQueryExecute( unit_query );
}

return( kbUnitQueryGetResult( unit_query, index ) );
}


void initializeMainBase( float radius = 38.0 )
{
/* We are assuming that AutoBase0 is the only base of the context AI player at this point.
Therefore, we can skip the filtering statements that determine the proper base to destroy,
because only one base exists anyway. However, be careful in custom scenarios where the scenario
designers may make multiple and separated groups of buildings which the AI may consider as bases. */

// First of all, memorize the location of the first base:
vector baseLocation = kbBaseGetLocation( cMyID, kbBaseGetMainID( cMyID ) );

// Now, destroy the base:
kbBaseDestroyAll( cMyID );

// And create a new one:
int newBaseID = kbBaseCreate( cMyID, "My BIG Main Base", baseLocation, radius );
kbBaseSetMain( cMyID, newBaseID, true ); // Make it the main base
kbBaseSetMilitary( cMyID, newBaseID, true ); // Make it military, so the AI considers it in certain military tasks
kbBaseSetEconomy( cMyID, newBaseID, true ); // Make it economic, so tha AI considers it in certain economic tasks

// Assign all of our starting units to the new base:
// kbUnitCount( cMyID, cUnitTypeAll, cUnitStateAlive ) returns the total number of units and buildings the AI has
for( unitIndex = 0; < kbUnitCount( cMyID, cUnitTypeAll, cUnitStateAlive ) )
{
int unit = findUnit( cUnitTypeAll, cMyID, unitIndex );
kbBaseAddUnit( cMyID, newBaseID, unit );
}

kbBaseSetActive( cMyID, newBaseID, true ); // Make it active, or else it's useless

kbSetTownLocation( baseLocation ); // Sets the location of the main town to be right where the base's center is
}


void main( void )
{
kbAreaCalculate(); // From now on, never forget to call this function
initializeMainBase( 60.0 ); // Destroy AutoBase0 and create a new base with a wider radius

// For this simple scenario, we only need the Root Escrow so let's ditch the others:
kbEscrowSetPercentage( cEconomyEscrowID, cAllResources, 0.0 ); // 0%
kbEscrowSetPercentage( cMilitaryEscrowID, cAllResources, 0.0 ); // 0%
kbEscrowSetPercentage( cRootEscrowID, cAllResources, 1.0 ); // 100%
kbEscrowAllocateCurrentResources();

aiSetResourceGathererPercentageWeight(cRGPScript, 1.0);
aiSetResourceGathererPercentageWeight(cRGPCost, 0.0);
aiNormalizeResourceGathererPercentageWeights();

// 70% of the Settlers will gather Food:
aiSetResourceGathererPercentage(cResourceFood, 0.7, false, cRGPScript);
// 20% Wood
aiSetResourceGathererPercentage(cResourceWood, 0.2, false, cRGPScript);
// and 10% Gold
aiSetResourceGathererPercentage(cResourceGold, 0.1, false, cRGPScript);
aiNormalizeResourceGathererPercentages(cRGPScript);

int baseForGathering = kbBaseGetMainID( cMyID );
kbBaseSetMaximumResourceDistance( cMyID, baseForGathering, 100.0 ); // Don't gather more than 100 meters away from the base
aiSetResourceBreakdown( cResourceFood, cAIResourceSubTypeHunt, 1, 70, 1.0, baseForGathering );
aiSetResourceBreakdown( cResourceFood, cAIResourceSubTypeEasy, 1, 60, 1.0, baseForGathering );
aiSetResourceBreakdown( cResourceWood, cAIResourceSubTypeEasy, 1, 50, 1.0, baseForGathering );
aiSetResourceBreakdown( cResourceGold, cAIResourceSubTypeEasy, 1, 50, 1.0, baseForGathering );

// Set up an explore plan:
// First of all, we create the plan (here, we're naming it Walking with my dog):
int explore_plan = aiPlanCreate( "Walking with my dog", cPlanExplore );
/* Make it use units with aiPlanAddUnitType.
The three last parameters are: numberNeed, numberWant, numberMax
i.e. the plan must will always try to use "numberNeed" at the very least, if it can
but ideally, it would would "numberWant" and if there is an excess of units, it can
use "numberMax" */
aiPlanAddUnitType( explore_plan, cUnitTypeExplorer, 1, 1, 1);
aiPlanAddUnitType( explore_plan, cUnitTypeWarDog, 1, 1, 1);
/* Set the LOS Multiplier. Here, we set it to 4.0 (meter), which will make
the scout move in such a way that the line of sight on one pass very nearly touches
the LOS from the previous pass. If we set it to 6.0 or higher, some black space would be
left between passes but it would cover ground a bit faster. */
aiPlanSetVariableFloat( explore_plan, cExplorePlanLOSMultiplier, 0, 4.0 );
/* Plan priority is for when several plans want to use the same unit.
Personally, for a simple scout (not Explorer), I put high priority for scouting,
a lower priority for attacks and a low priority for defenses (except in emergency) */
aiPlanSetDesiredPriority( explore_plan, 90 );
// Activate the plan! The explorer and his dog will now explore and reexplore the map.
aiPlanSetActive( explore_plan, true );
}


// Set up a "maintain" plan and exit:
rule maintainSettlers
active
{
// First of all, we create the plan
int maintain_plan = aiPlanCreate( "Maintain 20 Settlers", cPlanTrain );
// Train Settlers:
aiPlanSetVariableInt( maintain_plan, cTrainPlanUnitType, 0, cUnitTypeSettler );
// Keep training until we have 20 Settlers. If a Settler dies and the number goes down, train again until we have 20
aiPlanSetVariableInt( maintain_plan, cTrainPlanNumberToMaintain, 0, 20 );
// Activate the plan!
aiPlanSetActive( maintain_plan, true );

xsDisableSelf();
}


// Send herdable to the Town Center and exit
// Place some Gaia Sheeps on random positions of the scenario if
// you generated it with a map without Gaia livestock
rule herdLivestock
active
{
// No more comment
int herd_plan = aiPlanCreate( "Herding", cPlanHerd );
aiPlanAddUnitType( herd_plan, cUnitTypeHerdable, 0, 50, 50 );
aiPlanSetVariableInt( herd_plan, cHerdPlanBuildingTypeID, 0, cUnitTypeTownCenter );
aiPlanSetActive( herd_plan, true );

xsDisableSelf();
}


// Build houses when we need population slots
rule buildHouse
active
minInterval 5 // Check out every 5 seconds
{
// kbGetPop() returns the amount of pop currently occupied by units and queued units
// kbGetPopCap() returns the maximum amount of pop affordable
// It's like the "X/Y" in the UI below the flag above resources: X = kbGetPop(), Y = kbGetPopCap()
if ( kbGetPop() + 5 <= kbGetPopCap() )
return; // Ignore the rest of the rule if there are more than 5 free space
if ( kbUnitCount( cMyID, cUnitTypeHouseMed, cUnitStateBuilding ) >= 1 )
return; // Ignore the rest of the rule if a house is already under construction
if ( kbUnitCount( cMyID, cUnitTypeHouseMed, cUnitStateABQ ) >= kbGetBuildLimit( cMyID, cUnitTypeHouseMed ) )
return; // Ignore the rest of the rule if the house build limit is reached
if ( aiPlanGetIDByTypeAndVariableType( cPlanBuild, cBuildPlanBuildingTypeID, cUnitTypeHouseMed ) >= 0)
return; // Ignore the rest of the rule if a build plan for building a house already exists

// Create the plan:
int build_plan = aiPlanCreate("BuildHouse", cPlanBuild);
// Make it have a very high priority, so that the builder interrupts its current task to build the house:
aiPlanSetDesiredPriority( build_plan, 100);
// Build a house:
aiPlanSetVariableInt( build_plan, cBuildPlanBuildingTypeID, 0, cUnitTypeHouseMed );
// Build it around the builder:
aiPlanSetVariableBool( build_plan, cBuildPlanInfluenceAtBuilderPosition, 0, true );
// To be precise, build it within 5 meters around the builder:
aiPlanSetVariableFloat( build_plan, cBuildPlanInfluenceBuilderPositionDistance, 0, 5.0 );
// Add +100.0 points to the build location's score if the builder is that close
aiPlanSetVariableFloat( build_plan, cBuildPlanInfluenceBuilderPositionValue, 0, 100.0 );
aiPlanSetVariableFloat( build_plan, cBuildPlanRandomBPValue, 0, 0.99 );
// Build it in the main base:
aiPlanSetBaseID( build_plan, kbBaseGetMainID( cMyID ) );
// Use a settler as builder
aiPlanAddUnitType( build_plan, cUnitTypeSettler, 1, 1, 1);
// Build 30 meters behind the main base:
vector backVector = kbBaseGetBackVector( cMyID, kbBaseGetMainID( cMyID ) );
vector buildLocation = kbBaseGetLocation( cMyID, kbBaseGetMainID( cMyID ) ) + backVector * 30.0;
aiPlanSetVariableVector( build_plan, cBuildPlanInfluencePosition, 0, buildLocation );
// Add 1 point to the build location's score if the location is within 20 meters of that vector.
aiPlanSetVariableFloat( build_plan, cBuildPlanInfluencePositionDistance, 0, 20.0 );
aiPlanSetVariableFloat( build_plan, cBuildPlanInfluencePositionValue, 0, 1.0 );
// Activate the plan! This plan will be destroyed by the AI as soon as it succeeds in building a house
aiPlanSetActive( build_plan, true );
}
And that's it, you've learned the traditional way for handling the AI's economy. See the aiMain.xs to see the "real" system, this tutorial is simply showing the basics - but who knows, this tutorial might get to the point where it shows a few techniques used in different games for economic strategizing, time will tell.

With the clean, unmodded TAD, you might be satisfied with this system. However, I must let you know that it's extremely bugged and we use different methods now for AI Economy - see the AI I wrote for Improvement Mod in 2018 that is still maintained by ageekhere currently.



CONCLUSION
From now on, even if you don't read the rest of this tutorial, you should be able to write a working AI from scratch. I know I haven't talked about the Homecity-related part (I promise it'll be explained in a future part of the tutorial) but I assure you that with what you've got in your possession at this point, you should be able to guess how it works by yourself like a programmer who can guess how an undocummented program works: you have some programming experience from Part I, you have some AI scripting experience from Part II, (I hope) you experimented tons of stuff from the Reference, and you also have the official TAD AI script (aiMain.xs) to take some examples or inspiration from. If you've not given up at this point, I congratulate you! You're ready for the actual thing!
Madagascar AlistairJah
Crossbow
Posts: 14
Joined: May 6, 2018
ESO: Aliwest
Location: Madagascar

Re: Writing an AI script for Age of Empires III (Part II)

Post by AlistairJah »

I removed the section about immediate tasks and added a section about planned tasks instead. I also moved the console from Part I to here, and introduced the debugger. Also added three new sections: context player, terrain analysis and player base. With what's been written so far, anyone should be able to write a very basic and primitive AI script. Next time, I will talk about the economy of the AI, and hopefully a small section about cards and decks.
Madagascar AlistairJah
Crossbow
Posts: 14
Joined: May 6, 2018
ESO: Aliwest
Location: Madagascar

Re: Writing an AI script for Age of Empires III (Part II)

Post by AlistairJah »

I fixed some typos and rewrote certain parts and also fixed a mistake in the Escrow chapter after Swan's comments from Discord. Thanks Swan ^^
No Flag iamutt
Crossbow
Posts: 1
Joined: Nov 7, 2021

Re: Writing an AI script for Age of Empires III (Part II)

Post by iamutt »

Google took me here, and I've learnt a lot from this tutorial, thank you very much. I am interested in AI scriptting of AOE3'DE. There seem to be many new functions and constants, and I have no idea how to get them. Could you please make another 'Reference' about DE?

Who is online

Users browsing this forum: No registered users and 5 guests

Which top 10 players do you wish to see listed?

All-time

Active last two weeks

Active last month

Supremacy

Treaty

Official

ESOC Patch

Treaty Patch

1v1 Elo

2v2 Elo

3v3 Elo

Power Rating

Which streams do you wish to see listed?

Twitch

Age of Empires III

Age of Empires IV