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)