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.

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.

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 and Deleting Ammunition

Creating Land Units At Sea

Unit Attack Bonus with Complementary Units In Square

Double Unit Promotion Revisited

Conclusion