Get Started With Lua Events Lesson 5: Even More Examples

From Scenario League Wiki
Jump to navigationJump to search

Back to Get Started With Lua Events

This lesson is currently under construction.

In this lesson, we will look at even more examples of events and functionality that can be created with our current level of functionality.

You can find the copy of events that I started Lesson 5 with here.

Fixing The Sea Unit Disbandment Bug

In Civilization II, when a city is captured, all the units supported by that city are disbanded. It turns out that the civ.scen.onCityTaken(function (city, defender) -> void) event trigger happens before units are disbanded. This means that we can use the civ.scen.onCityTaken event trigger to do things involving soon to be disbanded units.

One thing we can do is perform the disbanding action ourselves. The reason for this is that a bug exists that disbands all the units on a sea square if a ship there is disbanded (try it). This is obviously very undesirable, but we can override it in the case of city capture by doing the disbandment ourselves.

Essentially, when a city is captured we do a for loop over all the units in the game, and if the unit is supported by the captured city, or carried by a unit supported by it, then we delete the unit. Units disbanded due to city capture that are in other cities do not contribute shields to production, so we don't have to worry about that.


The first attempt at the code was:

local function doOnCityTaken(city,defender)
   for unit in civ.iterateUnits() do
   	if unit.homeCity == city or (unit.carriedBy and unit.carriedBy.homeCity == city) then
   		civ.deleteUnit(unit)
   	end
   end
end

This code goes through all the units in the game and checks if the unit in question has its home as the captured city or if it is being carried by a unit homed to the captured city. If either of those conditions is met, the unit is deleted.

I was expecting this code not to be correct because in some instances, the ship would be deleted before the unit being carried. However, when I introduced some print statements into the code, I found that a Celtic settler near Milan was being deleted (when I was capturing a Carthaginian city), since the game decided it was being "carried" by a deleted caravan that had the home city of the city I was capturing.

Running an appropriate print statement for all the units in the game revealed that many units are being "carriedBy" something that doesn't make any sense. I don't know if this is an artifact of the fact that this scenario was converted from an older version of the game, but it is best to account for it anyway.

Thus, my second iteration of the code is

local function doOnCityTaken(city,defender)
	for unit in civ.iterateUnits() do
		if unit.homeCity == city then
			for sameSquareUnit in unit.location.units do
				if sameSquareUnit.carriedBy == unit and sameSquareUnit ~= unit then
					print(sameSquareUnit,"deleted.")
					civ.deleteUnit(sameSquareUnit)
				end
			end
			print(unit,"deleted")
			civ.deleteUnit(unit)
		end
	end
end
civ.scen.onCityTaken(doOnCityTaken)

GetStartedWithLuaLesson5-01-DisbandingUnits.jpg

This code will not delete aircraft on a carrier (and will also not delete just created units that haven't been moved with the ship, presumably since they haven't been given a carriedBy entry yet), which probably isn't a big concern for our purposes, though it might be in other cases. However, this code will delete a unit in port that was carried there by a disbanded unit (though the ground unit itself is not supported by the captured city). It appears that carriedBy is not cleared upon entering port, so we should check that our unit is actually at sea before checking the square for units being carried by it. Since carriedBy isn't reliable, let's also check that the "carrying" unit is a sea unit and also has a carrying capacity before checking the square.

local function doOnCityTaken(city,defender)
	for unit in civ.iterateUnits() do
		if unit.homeCity == city then
			if unit.location.terrainType % 16 == terrainAliases.ocean and unit.type.domain == 2 and unit.type.hold > 0 then
				for sameSquareUnit in unit.location.units do
					if sameSquareUnit.carriedBy == unit and sameSquareUnit ~= unit then
						print(sameSquareUnit,"deleted.")
						civ.deleteUnit(sameSquareUnit)
					end
				end
			end
			print(unit,"deleted")
			civ.deleteUnit(unit)
		end
	end
end
civ.scen.onCityTaken(doOnCityTaken)

GetStartedWithLuaLesson5-02-DisbandingUnits-2.jpg

This function appears to work (with the exception of air units surviving a carrier disbandment).

Keeping Units Supported by a Captured City

Instead of disbanding a unit when its home city is captured, we could give it a new home city instead. It would be fairly natural to transfer support to either the nearest friendly city to the city captured, or to the nearest friendly city to the now unsupported unit. However, there is a complicating problem: cities can only support a maximum number of units, and it wouldn't do to transfer unit support only to have units disbanded anyway since there weren't enough shields to support them.

What we'd like to do is to determine if a city can support more units, and if not, exclude it from the list of cities that we're considering transferring the unit to. Unfortunately, there isn't an easy way to do this. While the command city.totalShield excludes waste from the result it returns, it does include shields paid for supporting units. Hence, we can't simply check if the result of that function call is 0. Also, since there isn't a command to find how many units the city is supporting, we don't have an easy way to determine if the city can support extra units.

Since there isn't an automatic way to determine how many units a city is supporting, we'll have to find the information ourselves. For a given city, we can simply go through all the units in the game and figure out which of those units is supported by the city in question, and make a count. However, this will require going through the entire list of units every time we need to find out this information, which could end up being quite a few times.

Instead, we will populate a table with the number of units supported by a city, so we only have to go through the unit list one time for this purpose.

We'll use the city id number as the key for our table. For each unit in the game, if the unit requires support, we'll increment the value corresponding to the id number of its home city. Units no longer on the map do not require support, so we'll have to check for that (deleted units can still be in the unit list, they just won't have valid map coordinates). We'll also have to check if the unit has the role of diplomat or caravan, since they, then, won't require support. We won't check for free support under fundamentalism, since such units still use up one of the government's support slots. (Try putting a bunch of fanatics in a city, then add another unit. That other unit will cost support.)


The code to do this is as follows:

local function doOnCityTaken(city,defender)
	local citySupportTable = {}
	for unit in civ.iterateUnits() do
		if unit.homeCity and  civ.getTile(unit.location.x,unit.location.y,unit.location.z) and unit.type.role <= 5 then
			citySupportTable[unit.homeCity.id] = citySupportTable[unit.homeCity.id] or 0
			citySupportTable[unit.homeCity.id] = citySupportTable[unit.homeCity.id]+1
		end
	end
	local function canSupportAnotherUnit(city)
		local freeSupport = 0
		local govtNumber = city.owner.government
		if govtNumber <= 1 then
			-- anarchy or despotism
			freeSupport = city.size
		elseif govtNumber == 2 then
			-- monarchy
			freeSupport = civ.cosmic.supportMonarchy
		elseif govtNumber == 3 then
			-- communism
			freeSupport = civ.cosmic.supportCommunism
		elseif govtNumber == 4 then
			freeSupport = civ.cosmic.supportFundamentalism
		end
		-- make sure citySupportTable has an entry for this city
		citySupportTable[city.id] = citySupportTable[city.id] or 0
		if freeSupport+city.totalShield - citySupportTable[city.id] > 0 then
			return true
		else
			return false
		end
	end
	local function taxiDistance(tileA,tileB)
		return math.abs(tileA.x-tileB.x)+math.abs(tileA.y-tileB.y)
	end
	for unit in civ.iterateUnits() do
		if unit.owner == defender and unit.homeCity == city and civ.getTile(unit.location.x,unit.location.y,unit.location.z) then
			local bestCitySoFar = nil
			local bestDistanceSoFar = 1000000
			for candidateCity in civ.iterateCities() do
				if candidateCity.owner == defender and canSupportAnotherUnit(candidateCity) 
					and taxiDistance(candidateCity.location,unit.location) <bestDistanceSoFar then
					bestCitySoFar = candidateCity
					bestDistanceSoFar = taxiDistance(bestCitySoFar.location,unit.location)
				end
			end
			unit.homeCity = bestCitySoFar
			if unit.type.role <= 5 then
				citySupportTable[bestCitySoFar.id]= (citySupportTable[bestCitySoFar.id] or 0)+1
			end
		end
	end
end


GetStartedWithLuaLesson5-03-RehomeUnits.jpg

Let us take a closer look at this code

	local citySupportTable = {}
	for unit in civ.iterateUnits() do
		if unit.homeCity and  civ.getTile(unit.location.x,unit.location.y,unit.location.z) and unit.type.role <= 5 then
			citySupportTable[unit.homeCity.id] = citySupportTable[unit.homeCity.id] or 0
			citySupportTable[unit.homeCity.id] = citySupportTable[unit.homeCity.id]+1
		end
	end

This part of the code populates a table that counts how many units each city in the game is supporting.

if unit.homeCity and civ.getTile(unit.location.x,unit.location.y,unit.location.z) and unit.type.role <= 5 then

This checks if the unit actually has a home city, and if the unit is actually on the map (destroyed units remain for a while, but have a location that does not correspond to a map tile). Also, units with role 6 or 7 do not require support, and so do not contribute to the count.

	citySupportTable[unit.homeCity.id] = citySupportTable[unit.homeCity.id] or 0
	citySupportTable[unit.homeCity.id] = citySupportTable[unit.homeCity.id]+1

The first line makes sure that there is an entry in citySupportTable corresponding to the unit's home city. If the city hasn't been put there yet, citySupportTable[unit.homeCity.id] will be nil and nil or 0 evaluates to 0, so an entry is placed in the table. Since we now know that the entry exists and is an integer, we add 1 to the count for the new unit.

local function canSupportAnotherUnit(city)
	local freeSupport = 0
	local govtNumber = city.owner.government
	if govtNumber <= 1 then
		-- anarchy or despotism
		freeSupport = city.size
	elseif govtNumber == 2 then
		-- monarchy
		freeSupport = civ.cosmic.supportMonarchy
	elseif govtNumber == 3 then
		-- communism
		freeSupport = civ.cosmic.supportCommunism
	elseif govtNumber == 4 then
		freeSupport = civ.cosmic.supportFundamentalism
	end
	-- make sure citySupportTable has an entry for this city
	citySupportTable[city.id] = citySupportTable[city.id] or 0
	if freeSupport+city.totalShield - citySupportTable[city.id] > 0 then
		return true
	else
		return false
	end
end

Since this is a local function defined within doOnCityTaken, it will only be available within doOnCityTaken. This function will check if a city can support another unit. To do this, we add up the free unit support the city gets due to its government and the number of shields the city is reported as producing. If this is more than citySupportTable says the city is supporting, then the city is able to support additional units, so we can return true. Otherwise, the function returns false.

local function taxiDistance(tileA,tileB)
	return math.abs(tileA.x-tileB.x)+math.abs(tileA.y-tileB.y)
end
for unit in civ.iterateUnits() do
	if unit.owner == defender and unit.homeCity == city and civ.getTile(unit.location.x,unit.location.y,unit.location.z) then
		local bestCitySoFar = nil
		local bestDistanceSoFar = 1000000
		for candidateCity in civ.iterateCities() do
			if candidateCity.owner == defender and canSupportAnotherUnit(candidateCity) 
				and taxiDistance(candidateCity.location,unit.location) <bestDistanceSoFar then
				bestCitySoFar = candidateCity
				bestDistanceSoFar = taxiDistance(bestCitySoFar.location,unit.location)
			end
		end
		unit.homeCity = bestCitySoFar
		if unit.type.role <= 5 then
			citySupportTable[bestCitySoFar.id]= (citySupportTable[bestCitySoFar.id] or 0)+1
		end
	end
end

This code checks every unit to see if it is on the map and supported by the city that has just been captured. If so, code to find the nearest eligible city to move support to is executed. This code is similar to other code to find the nearest city that we've seen, the difference being that we also check if the city can support units in addition to whether the city is owned by the correct civ.

unit.homeCity = bestCitySoFar
if unit.type.role <= 5 then
	citySupportTable[bestCitySoFar.id]= (citySupportTable[bestCitySoFar.id] or 0)+1
end

This transfers the home city of the unit, and, if necessary, updates the citySupportTable to reflect that a new unit is supported.

Comment out the old doOnCityTaken function and write this one into the events instead. Test it out.

Bitwise Operations

Before we begin, I should mention that you shouldn't worry too much if you find this section particularly difficult. There have already been discussions of building a "library" of code that will perform the functionality which at the moment requires the use of bitwise operations. I include this section for a few reasons. The first is that the library doesn't exist yet at the time of writing, so you may have to use this functionality, or know what you have to ask for from someone else. The next section will give an example of this. Similarly, if a new version of ToTPP comes out, it may have new functionality that hasn't been made easier through library functions. Additionally, this will at least provide a starting point if you need to understand some code that someone else has written, or if you need to write some functionality yourself.

The smallest unit of data that a computer can deal with is called a "bit." The "bit" has exactly two possible states. We can call these "on" and "off," "true" and "false," or "1" and "0," however we like. The computer groups bits into "bytes," which consist of 8 bits. The 8 bits together can be interpreted as a number, by using the binary number system. When the game is storing a number in the byte (or, possibly 2 or 4 bytes joined together) everything is easy. The lua function gives you a number when you need it, and writes the number to the game memory that you tell it to write.

Often, however, the game is not storing a number in the byte (or several bytes) you are accessing, but is instead storing a bunch of "flags." For example, the game keeps track of all the terrain improvements on the map with one byte of data per tile. If the bit corresponding to a particular improvement is 1, then the tile has that feature, if the bit is 0, then it doesn't. Sometimes, two flags together indicate the presence of an improvement. If the "irrigation" and "mining" bit are both 1, that tells the game that there is farmland on that tile. There is a problem with this, however. When you access the improvements byte for a tile using tile.improvements, you will get an integer between -128 and 127 as your value. What's more, you will be setting tile.improvements to a new integer if you want to make a change. This presents the problem of converting between the 8 (or more) bits that have an individual interpretation as true or false and the number that they represent together. Lua represents most bytes using the twos complements system. I recommend this video for understanding how it works. (This video very briefly describes binary itself, though if you've never seen binary before you may need to find another reference.)

Twos complement: Negative numbers in binary

This website offers a two's complement binary converter.

If we're given an integer and we want to figure out if, say, the 4th bit is 0 or 1, we could do some math on that integer to figure out if the 4th bit is needed to express that number in binary. That's a bit of work, and we have another option: to use bitwise and. Bitwise and is called with the operator &, takes two integers as arguments, and returns an integer. The integer it returns is obtained by doing an "and" operation for each bit position. If the ith bit in both arguments is 1, then the ith bit in the returned value is also 1. Otherwise, the ith bit is 0.

A few examples should make this clear:

1 & 2 == 0

   0000 0001  1 in binary
&  0000 0010  2 in binary
   0000 0000  0 in binary

39 & 106 == 34

   0010 0111   39 in binary
&  0110 1010  106 in binary
   0010 0010   34 in binary

-106 & -47 == -112

   1001 0110  -106 in binary
&  1101 0001  - 47 in binary
   1001 0000  -112 in binary

Hence, if we want to find out if an integer, I, has the 4th bit set to 1, we compute

I & 8 == 8

Which will be true if and only if the 4th bit is set to 1:

   hgfe dcba  I in binary
&  0000 1000  8 in binary
   0000 d000  either 8 or 0 in binary

If d is 1, then the I & 8 == 8 evaluates to true, and if d is 0, then the expression evaluates to false.

To get the number corresponding to the nth bit (counting from the bit representing 1), you can compute 2^(n-1). It turns out that this will work even for the most significant bit. For example, if there are 8 bits, the leftmost bit represents -128 rather than 128. However, -128 & 128 == 128 evaluates to true, so you don't need to write a special case for when n is 8.

You can also specify an integer in hexadecimal by prefixing 0x, which might make it easier to specify a particular bit (or bits) to check, since hexadecimal has a nearer correspondence to binary than decimal. Unfortunately, Lua doesn't allow us to specify an integer directly in binary.

Now that we've figured out how to check if a bit is set, perhaps we want to set that bit. To do that we need to find the integer that will keep all other bits set or not set, but put the bit we care about to either 1 or 0 as we desire.

To set a bit to 0, we use bitwise and again, but this time with all bits set to 1 instead of 0, except the bit we want to set to 0.

   hgfe dcba
&  1110 1111
   hgf0 dcba

If we have the number 0001 0000 (16), we can get 1110 1111 by using bitwise not ~, which is to say, in this case, ~16

If we want to set a bit to 1, we can use bitwise or | to do so. Bitwise or produces a 1 in the result if either number has a 1 in the corresponding place.

   hgfe dcba
|  0001 0000
   hgf1 dcba

We'll see some usage of bitwise operations in the next section.

Generating Money With Units On Key Press

Something we might like to do is have units generate gold, perhaps to represent a leader collecting taxes or something like that. Generally speaking, we would like to do this with a key press, usually so that the unit can do something else instead of this task if we choose. In order to have this functionality, we must make events that are triggered by a key press, which we do using the event trigger civ.scen.onKeyPress . We used the onKeyPress trigger last lesson when testing our region specification code, but now let us look at it a bit more closely.

civ.scen.onKeyPress(doOnKeyPress) feeds the ID number of the key just pressed to the doOnKeyPress function that we've specified. From that information we use if statements within doOnKeyPress to govern which part of the code should be run when a particular key is pressed. This raises a question, however: How do we know what keyID number to use? An answer, and the one we'll be using here, is to write an onKeyPress event that shows us the key number that we just pressed. We won't want to keep this in our events permanently, but it will be useful to determine the id number of keys we might want to use.

Write an event that displays the keyID number of whatever key is pressed in a text box. Try this out, and get the ID numbers of keys that you might like to use. We'll be using 'k' for the next event, so make sure to get that number.

Scroll down for the answer.





Answer:

We only have to add one line of code to our doOnKeyPress function:

civ.ui.text(tostring(keyID))

The value for 'k' is 75.

Comment out the line of code.

Let us now write an event where legions can "plunder the countryside." If the player presses 'k' while the legion is within the city limits of a tribe with which that player is at war, some money is stolen from the city owner's treasury and given to the legion's owner. More money will be stolen if the legion is adjacent to the city. A bonus will be applied if the legion is on a tile with irrigation or mining.

We should first "plan" out the program. We will write a function that will run after we've determined that 'k' has been pressed and the active unit is a legion. The part of the event checking these things will be a part of doOnKeyPress. The function we write must first find the best city for the legion to plunder, and then plunder that city.

There are a couple ways of finding the best city to plunder. The first is to check every city in the game and determine if the legion is eligible to plunder that city, and if so, how much would be plundered. The second is to check the tiles nearby the legion for any potential cities. Since we are unlikely to want to change our scenario so a legion can plunder a city 3 or 4 tiles away, we will search nearby squares for cities. This means searching 20 squares for cities instead of checking every city in the game.

We'll want to find the best city to plunder. Since we've specified that adjacent cities yield more than cities 2 squares away, and a tile improvement bonus doesn't change with the city we're plundering, it may seem like we can choose the first city we find, provided we start our search with adjacent tiles. However, this may not be the case, since we might attempt to plunder a tribe with an empty treasury.

So, first, we build our plunder value function. We'll need some parameters for this to work:

parameters.legionBasePlunder = 3
parameters.legionAdjacentCityPlunderBonus = 3
parameters.legionIrrigationPlunderBonus = 4
parameters.legionMinePlunderBonus = 5
parameters.legionFarmPlunderBonus = 6

When executing your plan, you will likely think of new things that must be specified. In this case, we should specify the plunder available for farmland, just in case that comes up. Now, we write a function to figure out the plunder value of a city to a plundering unit. Since the distance between the city and the unit is involved, we'll need to feed both of those pieces of information into the function. We'll double check that the city is eligible for plunder and return a 0 if it is not.

We begin by checking that the city owner is at war with the unit owner. To do this we need to use tribe.treaties to determine if the tribes are at war. Hence, we access unit.owner.treaties[city.owner], but how do we interpret the integer that is output? One resource is Catfish's Cave's Test of Time file structure. Under the treaties section we find

GetStartedWithLuaLesson5-04-Treaties.jpg

This suggests that the bit given by 0x20 is the bit that indicates war between two tribes. Using what we learned from the previous section, we can check if that bit is set or not by using

unit.owner.treaties[city.owner] & 0x2000 == 0x2000

Which will evaluate to true if and only if the "war bit" is 1. We use 0x2000 and not 0x20, since we are accounting for the fact that there is already another byte's worth of data in this integer.

We can check that we are correct by using the lua console and checking the treaties between the various civilizations.

For example

civ.getTribe(1).treaties[civ.getTribe(7)] & 0x2000 == 0x2000

Returns true, but

civ.getTribe(1).treaties[civ.getTribe(2)] & 0x2000 == 0x2000

returns false instead.

To make sure this is correct, we can set the bit in question to 1 and back to 0 and check if the foreign minister tells us something different.

Indeed, if you enter

civ.getTribe(1).treaties[civ.getTribe(2)] = civ.getTribe(1).treaties[civ.getTribe(2)] | 0x2000 

Into the lua console, you will find that the Romans are now at war with the Carthaginians. Similarly, entering

civ.getTribe(1).treaties[civ.getTribe(2)] = civ.getTribe(1).treaties[civ.getTribe(2)] & ~0x2000

removes the war status for the Romans towards the Carthaginians.

If the sides are not at war, then plunderValue should return 0. This can be achieved with the following code. We also check that the city is not owned by the plundering unit.

if city.owner == unit.owner then
		return 0
elseif city.owner.treaties[unit.owner] & 0x2000 == 0 or unit.owner.treaties[city.owner] & 0x2000 == 0 then
		-- at least one civ is not considered to be at war with the other civ
		return 0
end

Next, we compute the maximum plunder, given the various parameters. This involves checking the improvements for the tile the unit is currently standing on. We again reference the Test of Time file structure.

GetStartedWithLuaLesson5-05-TileImprovements.jpg

We must check farmland first, since if there is farmland, the irrigation and mining bits will individually be true also. Hence, we only check for irrigation and mining after determining that there is no farmland.

local improvementInteger = unit.location.improvements
local plunderValue = parameters.legionBasePlunder
if improvementInteger & 0x0C == 0x0C then
	--farmland is on square
	plunderValue = plunderValue + parameters.legionFarmPlunderBonus
elseif improvementInteger & 0x04 == 0x04 then
	--irrigation is on square
	plunderValue = plunderValue + parameters.legionIrrigationPlunderBonus
elseif improvementInteger & 0x08 == 0x08 then
	-- mine is on square
	plunderValue = plunderValue + parameters.legionMinePlunderBonus
end

Elseif statements are used, since at most one of farmland, irrigation, and mining can be on a square.

Next, we check if the city is adjacent to the unit. Using the taxicab distance, two tiles are adjacent if the distance between them is exactly two. At this time, it makes sense to check if the city is completely out of plunder range. If the distance is greater than 4, the city is outside plunder range. But this doesn't account for the missing corner tiles of a city radius. We can eliminate these by checking if the x or y distance alone is 4.

local cityUnitDistance = math.abs(city.location.x-unit.location.x)+math.abs(city.location.y-unit.location.y)
if cityUnitDistance > 4 or math.abs(city.location.x-unit.location.x) == 4 or math.abs(city.location.y-unit.location.y) == 4 then
	-- unit not in city radius
	return 0
elseif cityUnitDistance == 2 then
	--unit adjacent to city
	plunderValue =plunderValue+parameters.legionAdjacentCityPlunderBonus
end
plunderValue = math.min(city.owner.money,plunderValue)
return plunderValue

All together, prior to debugging, the plunderValue code is

GetStartedWithLuaLesson5-06-PlunderValue.jpg

Next, we actually find the best city to plunder and perform the money transfer for the plunder action.

We'll need a table of tiles within a city radius of the unit (the unit will be in a city's radius if the city is within a city radius of the unit). This is likely to be useful again, so we'll make a function that takes a tile and returns a table of tiles that form the city radius of that tile.

local function cityRadius(tile)
	local offsets = {
	{0,0},--center
	{-2,0},{-1,1},{0,2},{1,1},{2,0},{1,-1},{0,-2},{-1,-1},--Inner Squares
	--[[{-4,0},]]{-3,1},{-2,2},{-1,3},--[[{0,4},]]{1,3},{2,2},{3,1},--[[{4,0},]]--Outer Squares
		{-3,-1},{-2,-2},{-1,-3},--[[{0,-4},]]{1,-3},{2,-2},{3,-1}, --
	}
	local radiusTable = {}
	for index,offset in pairs(offsets) do
		radiusTable[index] = civ.getTile(tile.x+offset[1],tile.y+offset[2],tile.z)
	end
	return radiusTable
end

GetStartedWithLuaLesson5-07-cityRadius.jpg

The table offsets gives the x and y values of tiles in the city radius relative to the x and y values of the center tile. The four corners of the outer diamond are included to make it clearer what was done, but commented out so they are not actually included in the computation.

It was easier and more straightforward to list all the offsets manually in this case than to figure out a programmatic way to find all the city square offsets. If there were many more offsets, or we had to deal with "circles" of varying sizes, it would have possibly made more sense to write instructions to get the necessary offsets.

After getting a table of offsets, we now need to construct the table of tiles, which we call radiusTable.

This can be done with a straightforward for loop. Since we don't care about order, we can just re-use the index from the offsets table for the corresponding entry in radiusTable. This saves us the trouble of keeping a counter index or anything of that nature.

We can test the cityRadius function with the following code, which creates archers in a city radius after a key press (4 in this case).

if keyID == 52--[ [4] ] then
	for index,tile in pairs(cityRadius(civ.getCurrentTile() or civ.getActiveUnit().location)) do
		civ.createUnit(unitAliases.archers,civ.getCurrentTribe(),tile)
	end
end

GetStartedWithLuaLesson5-08-cityRadiusTest.jpg

Be sure to test this on the edges of the map, to make sure the code can handle those cases.

The code does indeed handle those cases, since civ.getTile just returns nil if there is no corresponding tile, and so it is just as if radiusTable didn't have an entry for the corresponding index. As long as we do not expect every index to have a value, this should work fine.

Next, we actually construct the function to make legions plunder the countryside. My code before debugging is:

local function legionPlunder(unit)
	if not unit.type == unitAliases.legion then
		return
	end
	local bestCitySoFar = nil
	local bestPlunderSoFar = 0
	for index,tile in pairs(cityRadius(unit.location)) do
		if tile.city and plunderValue(tile.city,unit) > bestPlunderSoFar then
			bestCitySoFar = tile.city
			bestPlunderSoFar = plunderValue(bestCitySoFar,unit)
		end
	end
	if bestCitySoFar == nil then
		civ.ui.text("There is no suitable city for this legion to plunder.")
		return
	else
		unit.owner.money = unit.owner.money + bestPlunderSoFar
		bestCitySoFar.owner.money = math.max(0,bestCitySoFar.owner.money-bestPlunderSoFar)
		unit.moveSpent = unit.type.move*totpp.movementMultipliers.aggregate
		civ.ui.text(unit.owner.adjective.." "..unit.type.name.." plunders the countryside near the "
						..bestCitySoFar.owner.adjective.." city of "..bestCitySoFar.name..".  "..bestPlunderSoFar..
						" gold coins diverted from the "..bestCitySoFar.owner.adjective.." treasury to the "..
						unit.owner.adjective.." coffers.")
	end
end

The code in doOnKeyPress that activates the trigger is

	if keyID == 75 --[ [k] ] and civ.getActiveUnit() and civ.getActiveUnit().type == unitAliases.legion then
		legionPlunder(civ.getActiveUnit())
	end

GetStartedWithLuaLesson5-09-legionPlunderCode.png

GetStartedWithLuaLesson5-10-legionPlunderCode2.png

My only bit of "debugging" was to put the new parameters into the parameters table. Beyond that, the code appears to be fine. However, if there is no city to plunder (or if the city to plunder has 0 gold in the treasury) the legion doesn't spend any movement points. If desired, this could be fixed by adding unit.moveSpent = unit.type.move*totpp.movementMultipliers.aggregate to the case bestCitySoFar==nil.

Creating Land Units At Sea

I am told that in the events "macro" system that shipped with Test of Time, creating land units at sea is a process that involves changing an ocean tile to a land tile before creating the units, and then changing it back afterward, and dealing with the fact that changing terrain deletes one of the units on the tile.

Using Lua for events makes the process much more straightforward. As long as the tile in question doesn't have a foreign unit on it, the civ.createUnit function will create the unit. (Note that if you use civlua.createUnit, it won't work, since that code tries to mimic the functionality of the events macro.)


Let us create an event such that Barbarian Trirems appear at sea loaded with a legion and an archer, with some probability each turn. For that we need:

parameters.barbTriremeProbability = 1

We'll set the probability to 1, since that will make it easier to test the event.

We will also need to specify the square on which to produce the unit, and, perhaps, a couple of alternate squares to try if the first choice is covered.

parameters.barbTriremeLocations = {{35,31},{34,32},{33,31},}

Now, we'll implement the functionality as part of doThisOnTurn(turn)

if math.random() < parameters.barbTriremeProbability then
	for __,location in ipairs(parameters.barbTriremeLocations) do
		local barbTile = civ.getTile(location[1],location[2],0)
		if barbTile.defender == tribeAliases.barbarians or barbTile.defender == nil then
			local newTrireme = civ.createUnit(unitAliases.trireme,tribeAliases.barbarians,barbTile)
			newTrireme.homeCity=nil
			local newLegion = civ.createUnit(unitAliases.legion, tribeAliases.barbarians, barbTile)
			newLegion.homeCity=nil
			newLegion.order = 3
			--newLegion.carriedBy=newTrireme
			local newArcher = civ.createUnit(unitAliases.archer, tribeAliases.barbarians, barbTile)
			newArcher.homeCity=nil
			newArcher.order = 3
			--newArcher.carriedBy=newTrireme
			break
		end
	end
end

We must also add unitAliases.trireme=civ.getUnitType(32). While writing this code, I decided to add unitAliases.archer = civ.getUnitType(4), since I typically wrote that instead of archers, and I figured this was a "mistake" likely to happen again.

GetStartedWithLuaLesson5-11-CreateOnOcean.jpg

It turns out that the .carriedBy entry for a unit can't be set, but that didn't lead to any problems with this event.

Going through the code, I should note that ipairs is kind of like pairs, except that it is for tables indexed by 1,2,3, etc. for which there are no missing entries, and it proceeds in order of the indices.


The line if barbTile.defender == tribeAliases.barbarians or barbTile.defender == nil then checks if the tile in question is occupied by either a barbarian unit or no unit at all. If so, then the barbarian units can be placed. The break command after the instructions exits the innermost for loop (so no further squares have units placed on them). If the square is occupied, then the next square is tried.

The command newLegion.order = 3 makes the newly created unit sleep.

Issuing Move Unit Commands

Issuing a move unit command to a single unit is relatively simple. All you have to do is set unit.gotoTile to the desired destination (as a tile object). The corresponding unit.order is 12, but it is unnecessary to set this when issuing a Move Unit command.

We can try this with the barbarian trireme event we just made. If we don't give any order to the unit, it will sail towards Rome. Instead, however, we can make it sail towards Caralis instead.

First, comment out the "blocker unit" code we made in an earlier lesson, so that we don't have barbarian bombers being created and removed.

Next, add the following line after the trireme is created (we won't worry about parameters, etc. for this one)

   newTrireme.gotoTile=civ.getTile(37,43,0)

This code successfully diverts the barbarian trireme away from Rome, but it unloads as soon as it is adjacent to shore. This is probably due to the way barbarian units are programmed.

Let us now implement something closer to what the macro system allows. We're going to tell all Elephants in Selucia and Damascus (and the surrounding region) to converge on Raphia, for an attack.

   parameters.selucidElephantBox={xMin=78,xMax=92,yMin=46,yMax=60}
   parameters.maxSelucidElephantsInAttack=10000
   tribeAliases.selucids=civ.getTribe(5)
   tileAliases.selucidElephantDestination=civ.getTile(77,67,0)

Will define necessary parts of this event.

   doThisOnTurn(turn)
   --Other Stuff
   for unit in civ.iterateUnits() do
   	local elephantsMovedSoFar = 0
   	local boundary = parameters.selucidElephantBox
   	if unit.owner == tribeAliases.selucids and unit.type==unitAliases.elephant 
   		and not unit.owner.isHuman 
   		and elephantsMovedSoFar < parameters.maxSelucidElephantsInAttack
   		and inRectangle(unit.location, parameters.selucidElephantBox.xMin, 
   						parameters.selucidElephantBox.xMax,
   						parameters.selucidElephantBox.yMin,
   						parameters.selucidElephantBox.yMax) then
   		unit.gotoTile = tileAliases.selucidElephantDestination
   		elephantsMovedSoFar = elephantsMovedSoFar+1
   	end
   end

This code will find all elephants in the box, which are owned by the Selucids and are not controlled by a human player, and, regardless of their current orders, tell them to go to tile (77,67,0). If we want only some elephants to go, we would set parameters.maxSelucidElephantsInAttack to something small.

If we want only units that do not have current orders do do something, we check for unit.order==-1, but, AI units will usually have an order of some kind. In any case, if you're looking for units without orders, they're probably units you just created, and so you can give them goto orders when you create them.

GetStartedWithLuaLesson5-12-moveUnits.jpg

Double Unit Promotion Revisited

In lesson 4, we used musketeers as "elite archers" and allowed veteran archers to be promoted in combat. One problem we had to solve was that by the time we reach the onUnitKilled event trigger, an unit that entered battle as a rookie will already be promoted to veteran status. Since we don't want two promotions from the same battle, we had to find a way to check if the unit was already a veteran before it went into battle. Our solution at the time was to use the onActivateUnit event trigger to reset the veteran status of the active unit, so that we could at least prevent the double promotion of the attacking unit.

It will often happen that after you implement a new game mechanic that you want, you'll think of a better way to do it. In this case, we will use civ.scen.onResolveCombat(function (defaultResolutionFunction, defender, attacker) -> boolean) to set the veteran status of the attacking and defending unit.

The function provided to civ.scen.onResolveCombat will run before every round of combat. If it returns true, another round of combat will happen. If false, combat ends and a unit is always killed (if one of the units has 0 hp, that unit will be killed). The defaultResolutionFunction is provided by the game, and is how the game determines if another round of combat should happen. This event trigger's capabilities have not been extensively studied or documented as of the writing of this Lesson.

For our purposes, we need only to set the veteran status values for the attacking and defending unit during combat.

Comment out

   activeUnitCurrentlyVeteran = activeUnitCurrentlyVeteran or false

And in its place introduce

local attackingUnitCurrentlyVeteran = attackingUnitCurrentlyVeteran or false
local defendingUnitCurrentlyVeteran = defendingUnitCurrentlyVeteran or false


Next, in doOnActivation, comment out

activeUnitCurrentlyVeteran = unit.veteran

Now, we introduce the following new code

local function doOnResolveCombat(defaultResolutionFunction,defender, attacker)
	attackingUnitCurrentlyVeteran=attacker.veteran
	defendingUnitCurrentlyVeteran=defender.veteran
	return defaultResolutionFunction(defender, attacker)
end
civ.scen.onResolveCombat(doOnResolveCombat)

GetStartedWithLuaLesson5-13-EliteResolveCombat.jpg

This will record the veteran status of the combatants after each round of combat. If this were an expensive computation, I would consider being cleverer and making it happen only once. Code efficiency isn't usually important to us, but it is conceivable that even a small delay for each combat round could make combat take an uncomfortably long time.

Finally, we change the unitKilled event. We do this by changing the qualifyingVeteran line:

local qualifyingVeteran = winner.veteran and (activeUnitCurrentlyVeteran or winner.owner ~= civ.getCurrentTribe())

to

--local qualifyingVeteran = winner.veteran and (activeUnitCurrentlyVeteran or winner.owner ~= civ.getCurrentTribe())
local qualifyingVeteran = nil
if winner.owner == civ.getCurrentTribe() then
	qualifyingVeteran = attackingUnitCurrentlyVeteran
else
	qualifyingVeteran = defendingUnitCurrentlyVeteran
end

GetStartedWithLuaLesson5-14-EliteUnitKilled.jpg

If the winning unit's owner is the active tribe, that means the winner was the attacker, and so we should use the attacking unit's veteran status as recorded from combat.

I recommend testing this with Sun Tzu's War Academy so that promotions happen immediately. Similarly, make sure the elitePromotionChance parameter is set to 1, so that testing is quicker.


Conclusion