The AI, default.lua (1 7) - HWRM/KarosGraveyard GitHub Wiki
Intro
The AI in Homeworld is, a mess. The syntax is all over the place, there are a couple of logic bugs and is highly repetitive. But, for the most part anyway - it works. I spent a lot of time going over it and understanding it and thought id share what I learnt. This is part 1/7 which will go over the default.lua, classdef.lua, cpubuild.lua, cpubuildsubsystem.lua, cpumilitary.lua, cpuresearch.lua and cpuresource.lua.
oninit()
This is loaded by the game and looks for a function oninit()
. This is compulsory and the game will crash without it. The first part looks like this:
function oninit()
s_playerIndex = Player_Self()
sg_dobuild = 1
sg_dosubsystems = 1
sg_doresearch = 1
sg_doupgrades = 1
sg_domilitary = 1
cp_processResource = 1
cp_processMilitary = 1
sg_lastSpendMoneyTime = gameTime()
sg_spendMoneyDelay = 0
-- ...
The first 5 sg_
variables are for the campaign. This essentially prevents AI from doing certain things (ie researching, making military units etc).
cp_processResource
& cp_processMilitary
do not seem used by the AI, but it might be used by the engine.
sg_lastSpendMoneyTime
& sg_spendMoneyDelay
are used to keep track of how often the AI should be spending money. This is determined by the following code in the function:
if (g_LOD == 0) then
sg_spendMoneyDelay = 2.5
elseif (g_LOD == 1) then
sg_spendMoneyDelay = 2.25
elseif (g_LOD == 2) then
sg_spendMoneyDelay = 2
end
g_LOD
is the games Level Of Difficulty, so if easy it's 2.5 seconds, normal is 2.25, hard is 2 seconds and expert will be 0 seconds. Seems like they forgot to include expert, or maybe it is intentional. Who knows?
ClassInitialize()
CpuBuild_Init()
CpuResearch_Init()
CpuMilitary_Init()
It then calls the Init's for all the other subsections of the AI. This being generating a list of all ships, the Init for how it manages what to build, research & spend on military. The subsystem and resource Init's are strangely called in the CpuBuild_Init()
. There is no particular reason why, you can move them to default with the other Init's. Personally, I would move them to the default.
sg_kDemandResetValue = SelfRace_GetNumber("ai_demand_reset_value", 4.0)
if (Override_Init) then
Override_Init()
end
sg_reseachDemand = -sg_kDemandResetValue
Rule_AddInterval("doai", 2.0 )
end
Finally, it then gets sg_kDemandResetValue
from the race that is being played, which determines the research demand, it runs Override_Init which can be used to add additional code to this function on a per-race basis. It then sets the sg_reseachDemand
as -sg_kDemandResetValue
and will run the AI every 2 seconds.
doai
doai is an important function, as this is what runs your AI. Now despite it's importance - it has a couple of error's for handling HW1 races. But let's start with going through the function.
function doai()
CacheCurrentState();
CalcOpenBuildChannels();
local timeSinceLastSubSysDemand = gameTime() - sg_lastSpendMoneyTime
local bigSpender = SelfRace_GetNumber("persona_bigspender", 0.0) -- 1.0 for HW1
Firstly, we start with updating the stats of the players and its enemies military's (CacheCurrentState()
) and the amount of build channels available (CalcOpenBuildChannels()
).
In a misleading name, a variable (timeSinceLastSubSysDemand
) is created which is the amount of time since money was last spent. It then gets the bigSpender
value from the race. This will be either 0 or 1, with 0 being HW2 races and 1 being HW1 races.
if (timeSinceLastSubSysDemand >= sg_spendMoneyDelay) and (bigSpender < 1.0) then
SpendMoney()
sg_lastSpendMoneyTime = gameTime()
end
Next, we get into how the AI will spend money. If it's time to spend money, and is Homeworld 2 race, it will spend money once and update the lastSpendMoneyTime
. bigSpender
is another strangely named variable which I could only assume the Homeworld 1 AI would spend money as quick as possible, where the Homeworld 2 AI was more paced. For cleanness I’d suggest changing bigSpender
if in the if statement to bigSpender == 0
, as it will only ever be 0 or 1.
Next, is the Homeworld 1 logic. There are 2 errors in this, plus awful syntax.
The logic is as follows. If the sg_spendMoneyDelay
is equal to or over 9, the player has over 2800 RU's and is a Homeworld 1 race, then spend money 6 times. Also increase sg_allowedBuildChannels
.
If the above is not the case, if the sg_spendMoneyDelay
is equal to or over 9, the player has less than 2800 RU's and is a Homeworld 1 race, then spend money 3 times. Also increase sg_allowedBuildChannels
.
If the above is not the case, if the sg_spendMoneyDelay
is equal to or over 9, the player has less than 1800 RU's and is a Homeworld 1 race, then spend money 2 times.
You may have noticed, if the player has exactly 2800 cash it won't spend any money. Not a big deal but something to note. Also, with the if statement in RED, it will never run. This is because if in the criteria for the GREEN if statement isn't met (if money isn't less than 2800, meaning its higher, which in this is case exactly 2800 (another bug in itself)) it will run the check of if its below 1800, which will never be the case and is totally pointless as the one above would of run this anyway.
To fix and clean this the entire section it should look like the following. Calling GetRU()
repeatedly is inefficient too so I have added a local variable which gets this once, plus for the minimum spend we are * a number by 1, which is also an inefficient waste.
local currentRU = GetRU()
if (9 >= sg_spendMoneyDelay) and (currentRU >= 2800) and (bigSpender == 1.0) then
SpendMoney()
SpendMoney()
SpendMoney()
SpendMoney()
SpendMoney()
SpendMoney()
sg_allowedBuildChannels = sg_allowedBuildChannels * 4
elseif (9 >= sg_spendMoneyDelay) and (currentRU < 2800 and currentRU >= 1800) and (bigSpender == 1.0) then
SpendMoney()
SpendMoney()
SpendMoney()
sg_allowedBuildChannels = sg_allowedBuildChannels * 2
elseif (9 >= sg_spendMoneyDelay) and (currentRU < 1800) and (bigSpender == 1.0) then
SpendMoney()
SpendMoney()
end
local cpuplayers_norushtime = 60
if CPUPLAYERS_NORUSHTIME5 ~= nil then
if IsResearchDone( CPUPLAYERS_NORUSHTIME5 ) == 1 then
cpuplayers_norushtime = 5*61.2
elseif IsResearchDone( CPUPLAYERS_NORUSHTIME10 ) == 1 then
cpuplayers_norushtime = 10*61.2
elseif IsResearchDone( CPUPLAYERS_NORUSHTIME15 ) == 1 then
cpuplayers_norushtime = 15*61.2
end
end
if (sg_domilitary==1) and (gameTime() > cpuplayers_norushtime) then
CpuMilitary_Process();
end
Next, the AI calculates if the no rush time has passed, and if so run the process to determine if it should attack.
Unsure of if there is a specific reason why it's 61.2 seconds per minute. A 'Homeworld' thing I guess. I have a feeling it's because there is a 1.2 second delay at the beginning of the game but stacking this for every minute seems wrong. Maybe it should be (x*60)+1.2. This is unnecessary maths anyway which will always be the same number. Id swap them out for 301.2, 601.2 and 901.2.
CacheCurrentState
function CacheCurrentState()
s_numFiSystems = 0
s_numCoSystems = 0
s_numFrSystems = 0
if (FIGHTERPRODUCTION ~= nil) then
s_numFiSystems = NumSubSystems(FIGHTERPRODUCTION) + NumSubSystemsQ(FIGHTERPRODUCTION)
end
if (CORVETTEPRODUCTION ~= nil) then
s_numCoSystems = NumSubSystems(CORVETTEPRODUCTION) + NumSubSystemsQ(CORVETTEPRODUCTION)
end
if (FRIGATEPRODUCTION ~= nil) then
s_numFrSystems = NumSubSystems(FRIGATEPRODUCTION) + NumSubSystemsQ(FRIGATEPRODUCTION)
end
s_totalProdSS = s_numFiSystems + s_numCoSystems + s_numFrSystems
Firstly, it tally's up the amount of fighter, corvette and frigate production system you have. Unsure why capital modules are not tallied too, 'Homeworld thing' maybe 😂?
s_militaryPop = PlayersMilitaryPopulation( s_playerIndex, player_total );
s_selfTotalValue = PlayersMilitary_Total( s_playerIndex, player_total );
s_enemyTotalValue = PlayersMilitary_Total( player_enemy, player_max );
s_militaryStrength = PlayersMilitary_Threat( player_enemy, player_min );
Next it gets the players total military population (s_militaryPop), the players military combat value (s_selfTotalValue), the strongest enemies combat value (s_enemyTotalValue) and the strongest enemy threat to the player (s_militaryStrength).
My beliefs of the definitions
-
s_militaryPop = the players military ship count
-
s_selfTotalValue = the combat value of the players ships
-
s_enemyTotalValue = the combat value of the strongest enemies’ ships
-
s_militaryStrength = is an unknown calculation (engine side) of the perceived threat of the player with the biggest threat, and if it is the biggest player calling it does the 2nd biggest. This is what is used through most of the AI code.
-
player_total = x (number of players, including AI)
-
player_enemy = 16 (static number despite how many players)
-
player_min = 0 (static number despite how many players)
-
player_max = 1 (static number despite how many players)
I'm unsure what effect the 2nd argument PlayersMilitary_Threat has.
s_enemyIndex = GetChosenEnemy()
s_militaryStrengthVersusTarget = 0
if (s_enemyIndex ~= -1) then
s_militaryStrengthVersusTarget = PlayersMilitary_Threat( s_enemyIndex, player_max )
end
end
It then calculates the threat of its chosen enemy (s_militaryStrengthVersusTarget
). This is only ever used once in the AI in cpubuild.lua where there is an if statement for when s_militaryStrengthVersusTarget
is less than 20, it will decrease the amount of enemy frigates it thinks are on the enemy military). It essentially causes the AI to do less anti-frigate ships unless it's chosen enemy is perceived to be a threat.
CalcOpenBuildChannels
CalcOpenBuildChannels decides the amount of ships the AI use to build things. For example, if you have 2 carriers you will have 4 build slots.
function CalcOpenBuildChannels()
local numShipsBuildingShips = NumShipsBuildingShips()
local numShipsBuildingSubSystems = NumShipsBuildingSubSystems()
local numShipsBuilding = numShipsBuildingShips + numShipsBuildingSubSystems
local researchItem = IsResearchBusy()
if (SelfRace_GetNumber("cfg_buildable_subsystems", 1.0) < 1.0) then
numShipsBuilding = numShipsBuildingShips
end
Firstly, it gets the number of ships building ships & subsystems (numShipsBuildingShips
& numShipsBuildingSubSystems
) and combines the number. It then also checks if anything is being researched (researchItem
).
Some races do not have subysystems, and if this is the case it ignores numShipsBuildingSubSystems
. This check seems unnecessary, as numShipsBuildingSubSystems()
would probably be 0 anyway - but would need checking.
local numItemsBuilding = numShipsBuilding + researchItem
local totalBuildShips = BuildShipCount()*2
local numCollecting = GetNumCollecting()
local numRUs = GetRU()
sg_allowedBuildChannels = numCollecting/5;
Next it combines the number of ships & subsystems being built and if something is being researched (numItemsBuilding
), works out the amount of build ships (totalBuildShips
) (*2 as you can build a ship and subsystem at the same time). It gets the number of collectors collecting (numCollecting
) and the players current RU's (numRUs
).
It then works out the allowed build channels based on the amount of collectors collecting * 5. So, if a player has 20 collectors it will have 4 build channels.
if (SelfRace_GetNumber("cfg_build_by_ships", 0.0) >= 1.0) then -- set for HW1!!!
sg_allowedBuildChannels = BuildShipCount()*2 -- make it totalBuildShips
end
If build by ships is set to 1 (or more), the build channels are determined from ships count rather than number of collects. It should just use the totalBuildShips
variable for efficiency. HW1 races are set to 1.
if (numRUs > 500) then
sg_allowedBuildChannels = sg_allowedBuildChannels + (numRUs-500)/1000
end
s_numOpenBuildChannels = sg_allowedBuildChannels - numItemsBuilding
s_shipBuildQueuesFull = 0
It then scales the allowed build channels on the amount of cash the player has. Then it deducts from the build channels the number of things currently being built leaving the remaining open build channels (s_numOpenBuildChannels
).
s_shipBuildQueuesFull = 0
local adjBuildEst = SelfRace_GetNumber("persona_build_ships_scalar", 1.0) -- 4.0 HW1
if (totalBuildShips >= numShipsBuilding*adjBuildEst) then
s_shipBuildQueuesFull = SelfRace_GetNumber("persona_build_ships_befull", 1.0) -- 0.0 HW1
end
adjBuildEst
gets a scalar from the race files that will artificially inflate how many ships it think's its building. The higher it is the less ships it will build at once.
A boolean variable is initialised for if the queues are full (s_shipBuildQueuesFull
) which if the amount of ships & subsystems that can be used at once is greater or equal to the number of ships currently being built * the adjBuildEst
scalar, set ship queues to full (s_shipBuildQueuesFull
). persona_build_ships_befull
is 0 across all races however meaning that this check will always be ignored. Should be changed on HW2 races in scripts/races/race/props/default.lua
if (s_numOpenBuildChannels <= -1.5) then
RemoveLeastNeededItem()
end
end
Next, if you are building more things than you are able to, remove the least needed item.
SpendMoney
Finally, one of the most important functions in default, is the SpendMoney. This is what trigger's functions to build a ship, subsystem or research. This function itself does not have any bugs, but it's a bit messy.
function SpendMoney()
if (s_numOpenBuildChannels > 0) then
local buildHasBeenDone = 0
if (sg_dobuild==1 and s_shipBuildQueuesFull==0 and sg_reseachDemand<0) then
if (CpuBuild_Process() == 1) then
s_numOpenBuildChannels = s_numOpenBuildChannels-SelfRace_GetNumber("persona_build_open_chan_adjust", 1.0) -- 0 HW1
sg_reseachDemand = sg_reseachDemand + 1
buildHasBeenDone = 1
end
end
Firstly, it checks if there are open build channels, if so, it then does its next check.
Next, if the AI is allowed to build (sg_dobuild
), if it's build queues (s_shipBuildQueuesFull
) aren't full, and if the research demand (sg_reseachDemand
) is lower than 0 it will run CpuBuild_Process()
. In the CpuBuild_Process()
if it does build something it will return 1, if it doesn't build something it returns 0.
It would then update the s_numOpenBuildChannels
, but every race with persona_build_open_chan_adjust
is set to 0. This is pointless for 2 reasons, 1) s_numOpenBuildChannels isn't used again and will be updated the next time doai()
is run 2) all races have persona_build_open_chan_adjust
set to 0 anyway. It then increases the research demand (sg_reseachDemand
) by 1 and set buildHasBeenDone
to 1.
if (s_numOpenBuildChannels > 0) then
if (sg_doresearch==1) then
local didResearch = CpuResearch_Process();
if (didResearch == 1) then
sg_reseachDemand = -sg_kDemandResetValue
Next it does a pointless check for s_numOpenBuildChannels
, this is because it has already been checked. Next it checks if the AI is allowed to research (sg_doresearch
). For a strange reason, the result of CpuResearch_Process()
is placed in didResearch
when it could just be in the if statement, but either way the function will run and if it does research something it will return 1, otherwise it returns 0. If it was able to start researching something it then resets the research demand.
else
if (sg_reseachDemand>=0 and sg_dobuild==1 and s_shipBuildQueuesFull==0 and buildHasBeenDone == 0) then
CpuBuild_Process()
end
end
If it wasn't able to research anything, it will check if the research demand (sg_reseachDemand
) is equal to or over 0, if the AI can build (sg_dobuild
), if the build queues aren't full (s_shipBuildQueuesFull
) and if no build has been done (buildHasBeenDone
). If this is true it will run CpuBuild_Process()
.
else
sg_reseachDemand = -sg_kDemandResetValue
end
end
end
If sg_doresearch
is 0 from the earlier if statement, it resets the sg_reseachDemand
(so the previous if statement in the description above (if (sg_dobuild == 1 and sg_reseachDemand < 0) then
) can keep on running if research is not allowed as otherwise sg_reseachDemand
will keep increasing by 1 every time it build something to a point where it’s over 0 and can't go down).
Here is a bug fixed & cleaned version of the function:
function SpendMoney()
if (s_numOpenBuildChannels > 0) then
local buildHasBeenDone = 0
if (sg_dobuild == 1 and s_shipBuildQueuesFull == 0 and sg_reseachDemand < 0) then
if (CpuBuild_Process() == 1) then
g_reseachDemand = sg_reseachDemand + 1
buildHasBeenDone = 1
end
end
if (sg_doresearch == 1) then
if (CpuResearch_Process() == 1) then
sg_reseachDemand = -sg_kDemandResetValue
elseif (sg_reseachDemand >= 0 and sg_dobuild == 1 and s_shipBuildQueuesFull == 0 and buildHasBeenDone == 0) then
CpuBuild_Process()
end
else
sg_reseachDemand = -sg_kDemandResetValue
end
end
end
Summary
The default.lua page does not do much decision making, but mostly runs initialisation's and run's functions which go on to do the decision making. It controls how much money to spend, if it can attack after the no rush time as well as calculates the size of its and its enemies’ military fleets and its build capacity.
I will publish a cleaned and fixed AI once this 7 part series is complete.
Bugs:
- (for races with
bigSpender
as 1, a hw1 race) If player has exactly 2800 cash the AI will not spend any money - (for races with
bigSpender
as 1, a hw1 race) If the player has under 2800 cash it will ignore the check for if the player has less than 1800 cash persona_build_ships_befull
is 0 across all races (throttles amount a race can build, would be used for HW1 races) (FIX: should be 1 one on HW2 races scripts/races/race/props/default.lua)- The no-rush time is over-compensated (by 6, 12 & 18 seconds) (is also doing the maths every 2 seconds, which is unnecessary)
Potential bugs
sg_spendMoneyDelay
time for expert is 0 (unexpectedly low, hard is 2 & easy is 2.5)- The AI is always calculating how it compares to the player with the biggest army, and not its chosen enemy
Other
persona_build_open_chan_adjust
is 0 across all races (is pointless anyway)