Get Started With Lua Events Lesson 6: Splitting Code into Multiple Files

From Scenario League Wiki
Jump to navigationJump to search

Back to Get Started With Lua Events

Introduction

In this lesson, we will take a look at how to split the code for a scenario's events into multiple files. There are several reasons to consider doing this.

The first reason is simply to organize your work. If you have a series of complicated events, the events.lua file can become very large very quickly. Once your events.lua file has thousands of lines of code in it, it may not be very obvious where the best place is to put that next helper function, or where to implement the function that runs a complicated event. Sometimes even finding the piece of code that has to be changed can be a bit of a hassle. In Over the Reich, I sometimes search for the associated civ.scen line, and then search for the exact name of the function I need. Occasionally, there may even be more searching to find what I actually have to change. With multiple files, it becomes easier to find things.

Another aspect of using multiple files is that you can make certain changes without worrying about breaking things elsewhere. If myBigComplicatedFunction relies on aModestHelperFunction for a certain part of its functionality, and a change to myBigComplicatedFunction also requires a change to aModestHelperFunction, you have to make sure that nothing else in your code also uses aModestHelperFunction, or, if something else does depend on it, that aModestHelperFunction either still provides the original functionality, or that all other functions that use aModestHelperFunction are suitably modified. If myBigComplicatedFunction is defined in its own lua module, then you don't have to worry about forgetting that aModestHelperFunction was used elsewhere, since it can't be accessed outside the module unless you specifically allow it to be.

A second reason to split events code into multiple files is to facilitate collaboration. If all of the events are in a single file, then only one person can make changes to the file at a time. If one partner starts changing an event, but has to do something else before finishing, everyone waits on one person's delay. However, if most aspects of the scenario are separated into individual files, everyone can work on his or her own piece of the puzzle without depriving others of the opportunity to work at that time.

A third reason to split events into multiple modules is to facilitate reusing code. If everything required to implement an event is in its own file (or files), it becomes much easier for other creators to use your functionality in their scenario. Since everything they need is already in one place, they don't have to find every last helper function, or concern themselves with naming conflicts.

Two Simple Examples

Let us look at the top of the events.lua file. You can get the version that I started lesson 6 with here.

GetStartedWithLuaLesson6-01-DefaultRequire.jpg

We get code from other files by using the command require along with a string specifying the file name (except for the .lua extension). At the moment, we can only get code stored in the lua folder in the Test of Time directory. This is inconvenient if we're trying to distribute a scenario, since installing a scenario would require moving certain files to the lua directory, and those file names might conflict. Instead, we need to tell the Lua interpreter to search in the same folder that the events.lua folder is in.

To do this, we need the following code:

local eventsPath = string.gsub(debug.getinfo(1).source, "@", "")
local scenarioFolderPath = string.gsub(eventsPath, "events.lua", "?.lua")
if string.find(package.path, scenarioFolderPath, 1, true) == nil then
   package.path = package.path .. ";" .. scenarioFolderPath
end

You can probably cut and paste this code before the require lines in your events.lua file and not worry about how it works. However, I'll provide a brief explanation for reference.

The first line stores the location of the of the current file as a string in eventsPath.

The second line replaces events.lua in the eventsPath with ?.lua, which will tell the Lua interpreter to look for .lua files in the current directory (that is to say, the directory with the events.lua file).

The if statement adds scenarioFolderPath to the places for the Lua interpreter to search for code, if that directory is not already in the search path. Since the search path is kept active by the Lua interpreter, it will not be necessary to repeat these lines of code in every file that performs a require action as long as you are running the file as part of events. If you are writing some other kind of code, you will need to substitute "events.lua" with the name of the file with the code you are running. Note that for some reason, events.lua is always provided in lower case. However, for use in other situations, debug.getinfo(1).source uses the correct case for event files. I have only briefly tested this, so you may have to do some testing yourself if this situation comes up (or ask for help in the forums).

Now that we've told Lua where to look for our extra code, we can begin moving code to other files. Think of require as telling Lua to run the entire contents of another file as a function. We can then assign a value to whatever that file returns, which will usually be a table. A couple of examples will clarify.

For our first example, we will move our scenario's parameters to a separate file, and access it via require. This may not seem worthwhile in this example, but tables like this can get large in a hurry. At the time of writing, Over the Reich has over 100 lines of parameters ("specialNumbers"), and parameters are something that a designer might like to play with while collaborators make other implementations. Also, parameters will have to be referenced by other parts of the code, and a file that is required by events.lua cannot, in turn, require events.lua in order to get information. That means that we'll have to keep our parameters in a separate file anyway.

Cut the parameters table definition and paste it into a new file, classicRomeParameters.lua. For the last line, write

return parameters

Now, Near the top of events.lua, where the existing require lines are, add the eventsPath code from above and also the line

local parameters = require("classicRomeParameters")

GetStartedWithLuaLesson6-02-RequireParameters.jpg

Open the classicRome scenario and test out a couple of events to make sure everything is the same.

We might find it convenient to put a few tables into the same file. For this, we will simply return a table containing these tables, and give each table a name in the events.lua file.

Create a file classicRomeAliases.lua and move all the "aliases" tables to that file. Also move the getCityAt function.

At the bottom of the file, put the following lines:

return {
unitAliases=unitAliases,
tribeAliases=tribeAliases,
tileAliases=tileAliases,
cityAliases=cityAliases,
textAliases=textAliases,
terrainAliases=terrainAliases,
improvementAliases=improvementAliases,
}

Then, add the following lines to events.lua (in the require section)

local aliases = require("classicRomeAliases")
local unitAliases,tribeAliases,tileAliases,cityAliases,textAliases,
		terrainAliases,improvementAliases=aliases.unitAliases,aliases.tribeAliases,
		aliases.tileAliases,aliases.cityAliases, aliases.textAliases,aliases.terrainAliases,
		aliases.improvementAliases

Here, our classicRomeAliases module is returning a table with our "alias" tables. We then assign the entries in this returned table to local variables with the desired names. Here, I use multiple variable assignment, which I don't believe I've used before (even though Lua allows it), but these could just as simply have been assigned line by line.

GetStartedWithLuaLesson6-03-RequireAliases.jpg

Now, let us move the legion plundering code into its own file, which we'll call plunder.lua. First, copy over the legionPlunder function, and paste it near the bottom of the new file.

Now, let us go through the code of this function, and figure out what else we need in our plunder.lua module.

The very first part of the function is a check against unitAliases.legion. This means we need the unitAliases table, and so we copy the aliases require code to the top of plunder.lua. We don't need the path information, since that will be added in events.lua.

A few more lines down, we find that the function cityRadius is also used in legionPlunder. Therefore, we move that over as well. We also need the plunderValue function, so copy that over as well.

Next, we check if cityRadius relies on anything, but it doesn't.

Proceeding to plunderValue, we find that it relies on the parameters table, so require that as well. The function plunderValue does not appear to depend on anything else, so we're done moving functionality. If we missed something, the Lua interpreter will complain at some point anyway.

Next, we require the plunder module in events.lua, with the following line:

local plunder = require("plunder")

Next, for each of the functions in plunder.lua, we check if it is used somewhere in events.lua. We can do this by searching. plunderValue isn't used anywhere, so we're done with that. City radius is used. At the bottom of plunder.lua, we begin our return table, and have

return {
cityRadius=cityRadius,
}

Then, in events.lua, we replace the ocurance of cityRadius with plunder.cityRadius. Alternatively, we could have put

local cityRadius=plunder.cityRadius

after the line requiring plunder.

Similarly, for legionPlunder, we add legionPlunder=legionPlunder to the table that plunder.lua returns, and pre-append plunder. to legionPlunder in the one location where it occurs.

Now, we test the legion plunder function and the press 4 generate archers function to make sure everything works.

GetStartedWithLuaLesson6-04-RequirePlunder.jpg

Working With a Library

In programming terms, a "library" is a collection of code that can provide some specified functionality to a programmer without that programmer having to modify the code in the library. Thus far, you have been using the "civ" library to interact with the game, and there has been no need modify it in order to use it in our own events. (Unfortunately, in the case of the civ library, we don't actually know how to modify it.) This is in contrast to much of the code we have written thus far. For example, the code we wrote last lesson to make Selucid elephants attack Egypt is highly specific. You couldn't just cut and paste that code, change the argument of a single function, and get your own goto event. It is still a good example for how to write a goto event, but a user would still have to actually write the event.

If you plan to write some functionality for your scenario that others might find useful, it is worth spending a little extra effort to see if you can write that functionality in a form that will allow others to use it with minimal effort. If you can, then a little extra work on your part might save someone else the trouble of re-inventing the wheel, and allow them to complete their project sooner, or to work on some new feature for you to enjoy. Unfortunately, the Lua scenarios that have been completed at the time of writing haven't been that good at creating re-usable code, at least in part since we were all getting used to Lua. This will hopefully be improved in the future.

For now, we're going to look at a library which is called the General Library (unless someone comes up with a better name) and which can be found here. The purpose of this module is to provide relatively simple functionality that probably shouldn't be implemented every time it is needed. This is useful for our purposes, since we can look at relatively self contained and simple functions for our first examples. We'll create a library for generating munitions later.

This library would be used by invoking the line

local gen = require("generalLibrary")

If you wanted to make these functions available to the console for testing purposes, you would simply omit the local, thereby making gen a global variable.

Let us now take a look at a function provided by the General Library gen.hasIrrigation(tile)-->boolean. The very first line references the helper function toTile(tile or table)-->tile, so let us include that code, and look at it first.

-- toTile(tile or table)-->tile
-- gen.toTile(tile or table)-->tile
-- If given a tile object, returns the tile
-- If given coordinates for a tile, returns the tile
-- Causes error otherwise
-- Helper Function (provided to this library as toTile and gen.toTile)
local function toTile(input)
    if civ.isTile(input) then
        return input
    elseif type(input) == "table" then
        local xVal = input[1] or input["x"]
        local yVal = input[2] or input["y"]
        local zVal = input[3] or input["z"] or 0
        if type(xVal)=="number" and type(yVal)=="number" and type(zVal)=="number" then
            if civ.getTile(xVal,yVal,zVal) then
                return civ.getTile(xVal,yVal,zVal)
            else
                error("Table with values {"..tostring(xVal)..","..tostring(yVal)..
                        ","..tostring(zVal).."} does not correspond to a valid tile.")
            end
        else
            error("Table did not correspond to tile coordinates")
        end
    else
        error("Did not receive a tile object or table of coordinates.")
    end
end
gen.toTile = toTile

-- gen.hasIrrigation(tile)-->boolean
-- returns true if tile has irrigation but no farm
-- returns false otherwise
function gen.hasIrrigation(tile)
    tile = toTile(tile)
    local improvements = tile.improvements
    -- irrigation, but no mining, so not farmland
    if improvements & 0x04 == 0x04 and improvements & 0x08 == 0 then
        return true
    else
        return false
    end
end

GetStartedWithLuaLesson6-05-HasIrrigation.jpg

Let us begin by looking at the function toTile. The purpose of this function is to allow other functions to accept tile coordinates in a table instead of an actual tile object. The function tries to be reasonably permissive in the coordinates it will accept, allowing both integer indices and lowercase string indices, and by assuming that a missing z value corresponds to map 0. The function also tries to give reasonably helpful errors.

This brings up something that should be considered when making reusable code: what to do if you don't get values you were expecting. One option is to throw an error and bring up the Lua console. This is good for scenario creators, so they know something is wrong, but not so great for end users because the error stops the rest of the event from happening, should the mistake slip through playtesting.

The other option is for the code to fail silently, and try to do what it can. This is the way the old Test of Time macro system often fails. If Civ II doesn't "understand" the event, it just doesn't happen, and doesn't bother the player with the error. This means that the scenario will still mostly work, but it is harder to catch issues during playtesting. Sometimes, this means doing something "sensible" if the input isn't of the expected type, and might make the code easier to write, since you don't have to handle inputs differently.

A third option is to write your code such that both things can be done. At the top of your code, you put in a variable named "debugMode" or something, and make all the functions throw errors when true, and fail silently when false. Then you playtest with debugMode set to true, and release with debug mode set to false. However, this means extra work in writing your code and you also have to test everything with debug mode off.

For toTile, I went with the fail with error option, since that will allow every function that uses toTile to assume it is receiving proper data. It also seems unlikely that people will be regularly trying to check tiles that don't exist or have tables with tiles and units and improvement objects all in one. And if they do, "guarding" is fairly straightforward anyway (as we've done in previous lessons).

Although toTile is mainly meant to be a helper function for the General Library, it is also put into the gen table to be made available to end users, since it offers useful functionality. Hence, gen.toTile=toTile. Note that we could have given toTile a different name in the gen table that this module will return, such as gen.tableToTile=toTile. In fact, we could refer to the same function with multiple keys in the gen table, if we want to.

Next, we will look at gen.hasIrrigation.

The first thing to notice is that local is not being used to define this function. That is because we're putting the function for gen.hasIrrigation directly into the gen table that this module will return. We could define hasIrrigation as a local variable and then copy it into the gen table. However, there is a decent chance that the General Library will have more than 200 functions by the time everything is complete, since it is meant to provide a lot of building block functionality and keep it in one place, and we will run into trouble if we assign 200 local variables in a single function (which a required lua file acts as).

Perhaps the most striking thing is that we begin by changing the value of tile, the argument of the function. It looks like we're making a global variable named tile, but we're not. When you specify the names of the arguments for a function (in this case tile), you create local variables for that function, which can be given new values just as if you had explicitly used the local keyword.

Beyond that, we extract the integer representing the tile improvements into a local variable (which upon later reflection isn't really necessary), and check that the irrigation bit is set to 1 and the mining bit is set to 0. Refer to lesson 5 for why this works. In fact, we could have simply returned the value of the if statement, rather than specifying return true and return false, although it is very slightly clearer what is going on in the expanded version of the code.

Now, let us write a function to place irrigation on a square. It seems pretty clear that we should do nothing if the tile has a city, but we also have to decide what to do if there is mining or farmland on the tile, since those interfere with placing irrigation. We'll just remove them, on the basis that if we're trying to "place irrigation," then we want irrigation to be there when we are finished.

function gen.placeIrrigation(tile)
   tile = toTile(tile)

These lines open the function and convert a table of coordinates to a tile if necessary.

if tile.city then
   return
end

If the tile has a city, return so nothing else is done in the function.

    -- Set irrigation bit to 1
    tile.improvements = tile.improvements | 0x04
    -- Set mining bit to 0
    tile.improvements = tile.improvements & ~0x08
end

Set the irrigation bit to 1 and the mining bit to 0.

The functions in this library don't have to be complex, and in fact, most probably shouldn't be. They just have to make life easier for the end user. If you reach this lesson before the General Library is considered "complete," your assignment now is to submit some work for it. If you don't feel comfortable doing bitwise operations, you can test the work of others or submit functions that simplify other tasks. However, there are also some helper functions in the library to facilitate changing bits, so you might also consider working on the unit type flags functions. Remember, if you want to make functions available to the console for testing purposes, they must be global variables.

The Threshold Table and First Look at Metatables

Consider a scenario design situation that occurred in Over the Reich: a bomber is generating some bombs via a key press, and the number of bombs it should generate is dependant on its current remaining hitpoints. The more hitpoints the bomber has, the more bombs it should generate. What we want is to specify that if a bomber has between x and y hitpoints, it should produce 2 bombs. That is, for a given type of bomber, we want to have, for example,

hp >= 16 --> 4 bombs 16 > hp >= 12 --> 3 bombs 12 > hp >= 9 --> 2 bombs 9 > hp >= 4 --> 1 bomb 4 > hp >= 0 --> 0 bombs

Unfortunately, Lua tables do not naturally encode this kind of association by default. However, Lua does have a feature called metatables, which allows us to change the behaviour of tables. (This will be a much better way to make the specification than I ended up writing in Over the Reich.)

A metatable is a table that we can "attach" to another table, and then the metatable changes the behaviour of that table. We'll only look at metatables briefly here, but more information can be found here.

What we want is a data structure that can accept numerical "key values" that are between the key values explicitly specified, and return the appropriate value for those keys. We will call this data structure a threshold table, since we are specifying threshold values as keys. We can do this by assigning the following metatable:

local mt = { __index = function(thresholdTable,key)
                if type(key) ~= "number" then
                    return rawget(thresholdTable,key)
                else
                    local bestIndexSoFar = -math.huge
                    local bestValueSoFar = false
                    for index,value in pairs(thresholdTable) do
                        if type(index) == "number" and key >= index and index >= bestIndexSoFar then
                            bestIndexSoFar = index
                            bestValueSoFar = value
                        end
                    end
                    return bestValueSoFar
                end
            end,}

If we run setmetatable(myTable,mt), then whenever we ask for an index from myTable and that index doesn't currently exist in myTable, the function assigned to __index will run. Now let us examine the function.

If the type of the key is not a number, then we return whatever the table value is for that key. We use the function rawget, because the standard thresholdTable[key] will invoke this function again if the key doesn't exist, and so never return an answer (when we want it to return nil).

If the key type is a number, then we look for the largest numerical index that is also less than or equal to the key value. We return a default value of false if the key submitted is smaller than all numerical values. False is chosen over nil, so that a maximum value can be set by setting the value of a numerical index to false. Using the key [-math.huge] allows a return value to be set for all numbers (since all numbers are greater than -math.huge).

Since we don't want scenario creators to have to re-create threshold tables every time they need them, we'll add this functionality to the General Library

local thresholdTableMetatable = { __index = function(thresholdTable,key)
                if type(key) ~= "number" then
                    return rawget(thresholdTable,key)
                else
                    local bestIndexSoFar = -math.huge
                    local bestValueSoFar = false
                    for index,value in pairs(thresholdTable) do
                        if type(index) == "number" and key >= index and index >= bestIndexSoFar then
                            bestIndexSoFar = index
                            bestValueSoFar = value
                        end
                    end
                    return bestValueSoFar
                end
            end,}
-- gen.makeThresholdTable(table or nil)-->thresholdTable
function gen.makeThresholdTable(inputTable)
    inputTable = inputTable or {}
    return setmetatable(inputTable,thresholdTableMetatable)
end

GetStartedWithLuaLesson6-06-ThresholdTable.jpg

A Munition Generation Module

Now, let us write some re-usable code for a human player to generate units by pressing a key while an appropriate unit is active. This sort of code could be used for ranged attacks (generating a "munition") and also for recruiting mercenaries by paying gold.

The first thing we will want to do is to decide how our code will be used by the end user. This will, perhaps, change by the time we finish writing the code, but it will at least give us a starting point.

spawnUnit(generatingUnit, specificationTable)--> table of unitCreated

Our goal is that when a player attempts to generate a unit using another unit, they will provide the unit spawning the new unit, and a table of information specifying if a unit (or units) can be spawned and anything else that must happen while spawning (like paying money, expending movement points, etc.).

We have an option here. We could have the scenario creator provide the specification table as a required file for our module, or the creator can provide it at the time spawnUnit is called. I choose the latter for a couple of reasons. The first is that it allows the player to have more than one specification table, either because more than one key can spawn a unit, or because the specification can change over time. The other is reason is so that the creator can make the table in the events.lua file if desired (since our module can't require information from events.lua).

Having decided that our generateUnit events are going to be specified by a table, we have to decide what the contents of that table should be. It seems pretty reasonable to index the specificationTable with the index of the unit type of each unit that can generate using the information in the table. If a unit type id number is missing, that unit doesn't generate a munition (or recruit a unit).

The value for each unit type id in the specification table will be a table that specifies how and if that unit generates other units.

--specificationTable[unitType.id]={
--
-- goldCost = integer or nil
--      amount of gold it costs to generate a unit
--      absent means 0
-- minTreasury = integer or nil
--      minimum amount of gold in treasury to generate a unit
--      absent means refer to gold cost
--      (tribe will generate and be set to 0 if treasury is less than goldCost)
-- treasuryFailMessage = string or nil
--      A message to be displayed if the unit fails to spawn a unit due to an 
--      insufficient treasury
--      nil means no message
-- There are three ways to specify move costs, in full movement points,
-- in "atomic" movement points, and as a fraction of total movement points for the
-- unit type.  Use only one kind per unit type 

-- moveCost = integer or nil
--      movement points to be expended generating the unit
--      "full" movement points
--      absent means 0
-- minMove = integer or nil
--      minimum remaining movement points to be allowed to generate a unit
--      "full" movement points
--      absent means any movement points for land/sea, 2 "atomic" for air units
-- postGenMinMove = integer or nil
--      a unit will be left with at least this many movement points after
--      the generation function
--      absent means 0 for land/sea, 1 "atomic" for air units
-- moveCostAtomic = integer or nil
--      movement points to be expended generating the unit
--      refers to the unit.moveSpent movement points
--      absent means 0
-- minMoveAtomic = integer or nil
--      minumum remaining movement points to be allowed to generate a unit
--      referes to the unit.moveSpent movement points
--      absent means any movement points for land, 2 "atomic" for air units
-- postGenMinMoveAtomic = integer or nil
--      a unit will be left with at least this many movement points after
--      the generation function
--      absent means 0 for land/sea, 1 "atomic" for air units
-- moveCostFraction = number in [0,1] or nil
--      fraction of unit's total movement points expended generating the unit
--      round up to nearest "atomic" movement point
--      absent means 0
-- minMoveFraction = number in [0,1] or nil
--      fraction of unit's total movement points that must remain to be allowed
--      to generate a unit
--      absent means any movement points for land, 2 "atomic" for air units
--      round up to nearest "atomic" movement point
-- postGenMinMoveFraction = number in [0,1] or nil
--      a unit will be left with at least this many movement points after
--      the generation function, round up to nearest "atomic" move point
--      absent means 0 for land/sea, 1 "atomic" for air units
-- roundFractionFull = bool or nil
--      fractional movement cost and minimum are rounded up to full movement point
--      instead of atomic movement point
--      nil/false means don't
-- roundFractionFullDown = bool or nil
--      fractional cost and minimum are rounded down to full move point
--      nil/false means don't
-- minMoveFailMessage = string or nil
--      a message to be displayed if a unit is not generated due to insufficient
--      movement points.
--      nil means no message 
-- allowedTerrainTypes = table of integers or nil
--      a unit may only be generated if the tile it is standing on
--      corresponds to one of numbers in the table
--      nil means the unit can be generated on any terrain type
-- terrainTypeFailMessage = string or nil
--      message to be displayed if a unit is not generated due to standing
--      on the incorrect terrain 
-- requiredTech = tech object or nil
--      the generating civ must have this technology in order to generate
--      the unit
--      nil means no requirement
-- techFailMessage = string or nil
--      message to be displayed if a unit is not generated due to not having
--      the correct technology 
-- payload = boolean or string
--      if true, unit must have a home city in order to generate munitions
--      and generating munitions sets the home city to NONE
-- payloadFailMessage = string or nil
--      message to be displayed if a unit is not generated due to the 
--      payload restriction
-- payloadRestrictionCheck = nil or function(unit)-->boolean
--      If function returns false, the home city is not valid for giving the
--      unit a payload.  This will be checked when the unit is activated, when
--      the unit is given a new home city with the 'h' key and when the unit
--      tries to generate a munition
--      nil means no restriction
-- payloadRestrictionMessage = nil or string
--      message to show if a unit fails the payloadRestrictionCheck
-- canGenerateFunction = nil or function(unit)-->boolean 
--      This function applied to the generating unit must return true in order
--      for a unit to be spawned.  All other conditions still apply.
--      any failure message should be part of canGenerateFunction
--      absent means no extra conditions 
-- generateUnitFunction = nil or function(unit)-->table of unit
--      This function creates the unit or units to be generated
--      and returns a table containing those units
--      Ignore any specifications prefixed with * if this is used
--      nil means use other specifications 
--*generatedUnitType = unitType
--      The type of unit to be generated
--      can't be nil unless generateUnitFunction is used instead 
--*giveVeteran = bool or nil
--      generated unit(s) given veteran status if true
--      nil or false means don't give vet status
--      if true, overrides copyVeteranStatus
--*copyVeteranStatus = bool or nil
--      generated unit(s) copy veteran status of generating unit if true
--      nil or false means don't give vet status
--*setHomeCityFunction = nil or function(generatingUnit)-->cityObject
--      determines what home city the spawned unit should have
--      nil means a home city of NONE 
--*numberToGenerate = nil or number or thresholdTable or function(generatingUnit)-->number
--      if nil, generate 1 unit in all circumstances
--      if integer, generate that many units (generate 0 if number less than 0)
--      if number not integer, generate floor(number), and 1 more with
--      probability number-floor(number)
--      if thresholdTable, use remaining hp as the key, to get the number to create
--      if function, get the number as the returned value of the function 
-- activate = bool or nil
--      Activates one of the generated units if true.  If generateUnitFunction was used,
--      the unit at index 1 is activated, if index 1 has a value.  (if not, any unit in
--      the list might be chosen)

The very last entry brings forward a change that must be made to our unit generation specification. Lua's activate unit command doesn't run the unit activation trigger, even though the active unit is changed. Therefore, we must do that manually. Hence,

spawnUnit(generatingUnit, specificationTable)--> table of unitCreated

becomes

spawnUnit(generatingUnit,specificationTable,unitActivationCode)-->table of unitCreated

Perhaps we will change it again later.

I've decided to try a different instructional technique for this section. Instead of writing the code and then going through it section by section, I've recorded a video of my coding and testing this module, and I attempt to explain some of the stuff that I do along the way.

I'm not sure of the value of the video. If you find that there seems to be little useful content relative to the time it takes to watch, then feel free to skip it and continue the lesson. I don't teach anything "new" in it, so you won't miss anything crucial, although I do add a line totpp.movementMultipliers.road = 2, which fixes a bug when @COSMIC2 is not defined in the rules.txt. However, the video should be pretty close to how programming Lua can actually happen in the "real world." While I did give some thought to how to write the module before I started, I do attempt to solve problems in real time, and make mistakes that are not discovered for a while (including one where I try to explain how I solve a problem and then not actually do a crucial part).

You can find the code here. Even if you decide not to watch the video, download the code and try testing some of the features based on the specification above. If you find any bugs, submit a fix (or at least report the issue).

The video can be found here.

A Unit Attack Bonus for Complementary Units in Square

Something we might like to do is to give units an attack bonus if they attack from a square with some special unit, for example a "leader" unit. The Napoléon I scenario, for example, gives an attack bonus to units if a commander unit, such as Napoleon, is in the square when the unit is activated (the unit can subsequently move and still retain the bonus as long as no other unit is selected). We will now make a module providing the same functionality.

We first have to have a basic idea of how this functionality should work. When a unit is activated, the onActivateUnit event checks if the newly activated unit should get an attack bonus, based on the other units in the square. If the unit deserves an attack bonus, the corresponding unitType.attack entry is changed, and all the attack value of all other units is reset to the default value. If the activated unit also does not deserve an attack bonus, that value is also set to the default value.

This now leads us to some more possibilities. Should multiple bonus units lead to multiple bonuses or only apply the best one? For example, suppose a legion gets a bonus if an archer is also in the same square. Should two archers provide twice the bonus? Perhaps two archers should provide a larger bonus than one, but not twice as much. Perhaps a legion should also get a bonus if there is a catapult in the square, but that bonus should apply even if the maximum archer bonus is already achieved. It seems like perhaps there should be a "ranged unit" bonus, an "artillery unit" bonus and maybe a "cavalry" bonus which should all impact the legion's attack value. It's looking like we might end up demanding that a scenario creator make a big complicated table in order to use this functionality, even if they only have simple needs.

What we can do to alleviate this is to write a "simple" version of the event that is easy to use (and program), and also write a more complicated version that gives the expanded capability.

Specification Table for simpleAttackBonus

--simpleAttackBonusTable[activeUnit.type.id] ={[bonusUnitType.id]=bonusNumber}
--simpleAttackBonusTable.type = string
--if simpleAttackBonusTable.type == "addbonus" then
-- add the bonusNumber to the base attack
--if simpleAttackBonusTable.type == "addpercent"
-- add bonusNumber percent to the unit's attack
-- i.e. attack 6, bonusNumber 50, new attack 9
--if simpleAttackBonusTable.type == "addfraction"
-- add the fraction of the attack value to the attack,
-- i.e. attack 6, bonusNumber 0.5, new attack 9
-- if simpleAttackBonusTable.type == "multiplypercent" then
-- multiply the unit's attack by the bonusNumber precent
-- i.e. attack 6, bonusNumber 150, new attack 9
-- if simpleAttackBonusTable.type == "multiply" then
-- multiply the unit's attack by bonusNumber
-- i.e. attack 6, bonusNumber 1.5, new attack 9
--simpleAttackBonusTable.round = "up" or "down" or "standard" or nil
-- nil means "standard"
-- "up" means a fractional attack value after a bonus is rounded up
-- "down" means a fractional attack value after a bonus is rounded down
-- "standard" means a fractional attack value is rounded down
-- if fraction part is less than 0.5, and rounded up otherwise 

The relevant first draft of the code is as follows:

local function simpleAttackBonus(activeUnit,simpleAttackBonusTable,defaultAttackTable)
    -- reset all unit attack values
    for unitTypeID,attackValue in pairs(defaultAttackTable) do
        civ.getUnitType(unitTypeID).attack = attackValue
    end
    local unitBonusTable = simpleAttackBonusTable[activeUnit.type.id]
    if not unitBonusTable then
        -- unitBonusTable is nil (or false), so the active unit can't
        -- get a bonus
        return
    end
    -- find the best bonus. For all bonus types, the bonus will be greater than 0, 
    -- and for all bonus types, a larger number means a better bonus
    local bestBonusSoFar = 0
    for unit in activeUnit.location.units do
        if unitBonusTable[unit.type.id] and unitBonusTable[unit.type.id] > bestBonusSoFar then
            bestBonusSoFar = unitBonusTable[unit.type.id]
        end
    end
    if bestBonusSoFar == 0 then
        -- If no bonus unit is found, this will be true, so do nothing else
        return
    end
    -- set the new attack and return
    if string.lower(simpleAttackBonusTable.type) == "addBonus" then
        activeUnit.type.attack = activeUnit.type.attack+bestBonusSoFar
        return
    end
    local attackMultiplier = 1
    if type(simpleAttackBonusTable.type) ~= "string" then
        error([[simpleAttackBonus: simpleAttackBonusTable.type should be a string with
                one of the following values: "addbonus", "addpercent", "addfraction", "multiplypercent"
                or "multiply".  Actual value is ]]..tostring(simpleAttackBonusTable.type))
    elseif string.lower(simpleAttackBonusTable.type) == "addpercent" then
        attackMultiplier = (100+bestBonusSoFar)/100
    elseif string.lower(simpleAttackBonusTable.type) == "addfraction" then
        attackMultiplier = 1+bestBonusSoFar
    elseif string.lower(simpleAttackBonusTable.type) == "multiplypercent" then
        attackMultiplier = bestBonusSoFar/100
    elseif string.lower(simpleAttackBonusTable.type) == "multiply" then
        attackMultiplier = bestBonusSoFar
    else
        error([[simpleAttackBonus: simpleAttackBonusTable.type should be a string with
                one of the following values: "addbonus", "addpercent", "addfraction", "multiplypercent"
                or "multiply".  Actual value is ]]..tostring(simpleAttackBonusTable.type))
    end
    -- apply the bonus and round as appropriate
    if type(simpleAttackBonusTable.round) ~= "string" then
        activeUnit.type.attack = math.floor(activeUnit.type.attack*attackMultiplier+0.5)
        return
    elseif string.lower(simpleAttackBonusTable.round) == "up" then
        activeUnit.type.attack = math.ceil(activeUnit.type.attack*attackMultiplier)
        return
    elseif string.lower(simpleAttackBonusTable.round) == "down" then
        activeUnit.type.attack = math.floor(activeUnit.type.attack*attackMultiplier)
        return
    else
        activeUnit.type.attack = math.floor(activeUnit.type.attack*attackMultiplier+0.5)
        return
    end
end

Now let us go over this section by section.

GetStartedWithLuaLesson6-07-AttackBonus1.jpg

The first thing we do is to reset all the attack values of all the unit types. That way, if a bonus existed for the last unit activated, that bonus no longer applies. If a unit type is not in the defaultAttackTable, then we don't have the information to reset the attack value anyway, so we can safely ignore it by using the pairs function to iterate over the table.

Now, we must find out if the newly activated unit deserves a bonus. For that, we get the table associated with the active unit's type. If the value is nil instead of a table, then the unit's type is not in the simpleAttackBonusTable, so there is no bonus to apply and the function returns.

If the unit is eligible for bonuses, we then check the units in the square to see if any bonuses actually apply. We've seen this kind of code before. If the unit in the stack can provide a bonus, and that bonus is better than the best bonus already found, the unit now provides the best bonus. All bonus types that we can have are better if they are larger, and we are not comparing between bonus types, so a simple numerical comparison will do. All bonuses are larger than 0 in our formulation, so 0 is a good starting value in the search.

If the best bonus so far is still 0, there is no bonus, so the function can return.

At this point, it occurs to me that when searching for the best bonus, we should exclude the active unit from the search. That way, you can have a "strength in numbers" bonus, for example. The relevant change to the code is:

   for unit in activeUnit.location.units do
       if unit ~= activeUnit and unitBonusTable[unit.type.id] and 
			unitBonusTable[unit.type.id] > bestBonusSoFar then
           bestBonusSoFar = unitBonusTable[unit.type.id]
       end
   end

Next, consider

GetStartedWithLuaLesson6-08-AttackBonus2.jpg

Here, we apply the bonus that we've found. We use string.lower on simpleAttackBonusTable.type so that we don't have to worry about capitalization. That does, however, require a correction to "addBonus", since a lower case string will never be equal to that.

"addbonus" is the only type of bonus that is directly added to the existing attack value. All other bonus types are a form of multiplication. Since the addition is straightforward and doesn't require any rounding, the activeUnit's type attack value can be changed immediately, and the function execution ended with return.

When there is only one bonus being applied, all other types of bonus are different ways of expressing how to multiply the existing attack value to get a new one, so we simply convert to the appropriate multiplier, and provide an error message if the type of bonus is not one of the valid possibilities.

Finally, we round the attack value to an appropriate integer (since unit attack values must be integers), change the attack value of the active unit's type, and return to end the function execution.

Now, we integrate into events.lua by using

attackBonus = require("attackBonus")

The relevant integration code for events.lua is

local defaultAttackValueTable = {[unitAliases.legion.id]=4}
local simpleAttackBonusTable ={}
simpleAttackBonusTable.type = "addbonus"
simpleAttackBonusTable[unitAliases.legion.id] = {[unitAliases.archers.id]=3,[unitAliases.musketeer.id]=4}

local function doOnActivation(unit,source)
    munitions.payloadRestrictionCheck(unit,munitionSpecificationTable)
	attackBonus.simpleAttackBonus(unit,simpleAttackBonusTable,defaultAttackValueTable)
end

We can check that the bonus is being applied correctly by checking the Civlopedia entry for legion and looking at the attack value. The following cases must be tested (remember that activating a unit from the city screen doesn't run the onActivation event, so activate from the map).

Legion activated with neither archer nor musketeer (4attack)
Legion activated with archer, but no musketeer (7 attack)
Legion activated with musketeer, but no archer (8 attack)
Legion activated with archer and musketeer (8 attack)
Legion activated with 2 archer, no musketeer (7 attack)
Legion with no bonus, activated right after legion with bonus (4 attack)
Activate some other unit (should have regular attack value).

I recommend making a stack for each of these cases and activating them one by one. To facilitate the test, add the following line to doOnActivation

civ.ui.text(tostring(unit.type.attack))

You will want to comment out this line after testing.

Next, change simpleAttackBonusTable to do "addpercent"

simpleAttackBonusTable.type = "addpercent"
simpleAttackBonusTable[unitAliases.legion.id] = {[unitAliases.archers.id]=30,[unitAliases.musketeer.id]=70}

With no rounding set, standard rounding is used

Legion activated with neither archer nor musketeer (4attack)
Legion activated with archer, but no musketeer (5 (5.2) attack)
Legion activated with musketeer, but no archer (7 (6.8) attack)
Legion activated with archer and musketeer (7 attack)
Legion activated with 2 archer, no musketeer (5 attack)
Legion with no bonus, activated right after legion with bonus (4 attack)
Activate some other unit (should have regular attack value).

These numbers test both rounding up and rounding down.

Now, use

simpleAttackBonusTable.round="up"
Legion activated with archer, but no musketeer (6 (5.2) attack)
Legion activated with musketeer, but no archer (7 (6.8) attack)

Next,

simpleAttackBonusTable.round="down"
Legion activated with archer, but no musketeer (5 (5.2) attack)
Legion activated with musketeer, but no archer (6 (6.8) attack)


simpleAttackBonusTable.round="standard"
Legion activated with archer, but no musketeer (5 (5.2) attack)
Legion activated with musketeer, but no archer (7 (6.8) attack)

Next, we test "addfraction". Since we've tested all the rounding options, we don't have to do that again.

simpleAttackBonusTable.type = "addfraction"
simpleAttackBonusTable[unitAliases.legion.id] = {[unitAliases.archers.id]=0.3,[unitAliases.musketeer.id]=0.7}

Legion activated with archer, but no musketeer (5 (5.2) attack)
Legion activated with musketeer, but no archer (7 (6.8) attack)


simpleAttackBonusTable.type = "multiplypercent"
simpleAttackBonusTable[unitAliases.legion.id] = {[unitAliases.archers.id]=130,[unitAliases.musketeer.id]=170}

Legion activated with archer, but no musketeer (5 (5.2) attack)
Legion activated with musketeer, but no archer (7 (6.8) attack)

simpleAttackBonusTable.type = "multiply"
simpleAttackBonusTable[unitAliases.legion.id] = {[unitAliases.archers.id]=1.3,[unitAliases.musketeer.id]=1.7}

Legion activated with archer, but no musketeer (5 (5.2) attack)
Legion activated with musketeer, but no archer (7 (6.8) attack)

Before we finish, we should test a legion bonus for the legion.

simpleAttackBonusTable[unitAliases.legion.id] = {[unitAliases.legion.id]=2,[unitAliases.archers.id]=1.3,[unitAliases.musketeer.id]=1.7}

Legion alone should have 4 attack.
A second legion on the square should provide 8 attack instead.


Now, let us try to formulate a more complicated bonus system. One thing we mentioned is that we'd like the ability to get bonuses from multiple units, and to be able to get bonuses from different "classes" of units (like "artillery," "cavalry," and "ranged attackers." Something else we might want to do is to allow different kinds of bonuses. That is, maybe some units should provide a fixed +2 to any unit receiving the bonus, so that it is better at improving units with a low attack value, while other units should provide a percentage bonus (making them better at supporting units with an already high attack value). It turns out that both of these ideas are not too difficult to implement individually, but that trying to implement them together makes it problematic to find the best bonus.

Let us implement a bonus system with multiple categories but only one bonus type. In our specification, we will first specify a data type called "bonusCategory." It is still just a table (though we could add a metatable if we wanted), but it will help us make our specification clearer.

-- A "bonusCategory" is a table
-- bonusCategory = {[bonusUnitType.id]=bonusNumber, maxBonusUnits=integer or nil, nextBonusValue = fraction or nil}
-- In a bonusCategory, the value for bonusUnitType.id tells what the base bonus for that unit type is,
-- maxBonusUnits is the maximum number of units that can provide a bonus in this category,
-- nextBonusValue tells how much to reduce the bonus for each subsequent unit,
-- e.g. nextBonusValue = 0.7, and 5 units, which each have a bonusNumber=1
-- The total bonus is 1(0.7)^0+.7+(.7)(.7)+(.7)^3+(.7)^4 = 1+.7+.49+.343+.2401=2.7731
-- (which would be rounded as specified)
-- nil means nextBonusValue=1
--
-- If a unit type is in two bonus categories for the same unit, there is no guarantee that the
-- best bonus will be achieved

So, the bonus category is indexed by unitType id numbers for units that can give a bonus, and the corresponding bonus. It also specifies the maximum number of units that can give a bonus, and a number that governs how to reduce a bonus for extra units. That way, there can be diminishing returns for bonuses.

Next, we look at the specification for the categoryAttackBonusTable

--categoryAttackBonusTable[activeUnit.type.id] =table of bonusCategory
--categoryAttackBonusTable.type = string
--if categoryAttackBonusTable.type == "addbonus" then
-- add the bonusNumber to the base attack
--if categoryAttackBonusTable.type == "addpercent"
-- add bonusNumber percent to the unit's attack
-- i.e. attack 6, bonusNumber 50, new attack 9
-- for multiple unit bonuses, add up all the percents, then compute the bonus
-- i.e. 50% bonus and 50% bonus is 100% bonus, not 125%
-- attack 6 --> attack 12
-- nextBonusValue applied directly to bonusNumber, i.e. nextBonusValue=.5 and 2 50% bonus units become 50% + 25% 
-- so attack 6 --> 10.5
--if categoryAttackBonusTable.type == "addfraction"
-- add the fraction of the attack value to the attack,
-- i.e. attack 6, bonusNumber 0.5, new attack 9
-- for multiple unit bonuses, add up the fractions, then compute the bonus
-- i.e. .5 bonus and .5 bonus is 1+.5+.5= 2x bonus, not 2.25x
-- attack 6 --> attack 12
-- nextBonusValue applied directly to bonusNumber, i.e. nextBonusValue=.5 and 2 .5 bonus units become .5+.25 
-- so attack 6 --> 10.5
-- if categoryAttackBonusTable.type == "multiplypercent" then
-- multiply the unit's attack by the bonusNumber precent
-- i.e. attack 6, bonusNumber 150, new attack 9
-- bonuses are multiplied together, i.e. 150% and 150% yields 225% of original value
-- attack 6 --> attack 13.5
-- nextBonusValue applied to bonusNumber in excess of 100, i.e. nextBonusValue=.5 2 150% bonuses become 150% and 125%
-- so attack 6 --> 11.25
-- if categoryAttackBonusTable.type == "multiply" then
-- multiply the unit's attack by bonusNumber
-- i.e. attack 6, bonusNumber 1.5, new attack 9
-- bonuses are multiplied together, i.e. 1.5 and 1.5 yields 2.25x
-- attack 6 --> attack 13.5
-- nextBonusValue applied to bonusNumber in excess of 1, i.e. nextBonusValue=.5 and two 1.5 bonuses become 1.5 and 1.25
-- so attack 6 --> 11.25
--categoryAttackBonusTable.round = "up" or "down" or "standard" or nil
-- nil means "standard"
-- "up" means a fractional attack value after a bonus is rounded up
-- "down" means a fractional attack value after a bonus is rounded down
-- "standard" means a fractional attack value is rounded down
-- if fraction part is less than 0.5, and rounded up otherwise

-- If the active unit can get a bonus, go through each category and compute the bonus for that category.
-- Then, combine the bonuses for all categories to get the overall bonus, and round the result

With the bonusCategory defined, the value of the unit indices of categoryAttackBonusTable become easy to define. Most of the specification is showing what exactly the different bonus categories mean, so the end user can use them properly. It's not strictly necessary to offer so many choices to the end user on how to specify data, but it can be more convenient.

Here is the actual code (before testing) to implement this more general version of attack bonuses.

-- HELPER FUNCTION
-- findBestBonusInUnitStack(tile,bonusCategory,usedUnitTable) --> number or false
-- usedUnitTable is indexed by the i.d. number of units, and if the value for a unit is true,
-- then it has already been used for a bonus (and, so, is not eligible)
-- The function returns the bonus (before nextBonusValue is applied) if there is one, and marks the 
-- unit as used in the usedUnitTable
-- returns false if no unit qualifies
local function findBestBonusInUnitStack(tile,bonusCategory,usedUnitTable)
    local bestBonusSoFar = 0
   local bestUnitSoFar = nil
   -- find the best bonus. For all bonus types, the bonus will be greater than 0, 
   -- and for all bonus types, a larger number means a better bonus
   for unit in tile.units do
       if not usedUnitTable[unit.id] and bonusCategory[unit.type.id] 
           and bonusCategory[unit.type.id] > bestBonusSoFar then
           bestUnitSoFar = unit
           bestBonusSoFar = bonusCategory[unit.type.id]
       end
   end
   if bestUnitSoFar then
       usedUnitTable[bestUnitSoFar.id] = true
       return bestBonusSoFar
   else
       return false
   end
end
-- HELPER FUNCTION
-- computeCategoryBonus(activeUnit,categoryAttackBonusTable,bonusCategory,usedUnitTable)
-- Computes the bonus that the active unit will receive from this category, taking into 
-- account the type of bonus for the table
local function computeCategoryBonus(activeUnit,categoryAttackBonusTable,bonusCategory,usedUnitTable)
   local bonusType = string.lower(categoryAttackBonusTable.type)
   local bonusThusFar = nil
   if bonusType == "multiply" then 
       bonusThusFar = 1
   elseif bonusType == "multiplypercent" then
       bonusThusFar = 100
   else
       bonusThusFar = 0
   end
   local bonusDiscount = 1
   for i=1,(bonusCategory.maxBonusUnits or 10000) do
       local currentBonus =findBestBonusInUnitStack(activeUnit.location,bonusCategory,usedUnitTable) 
       if not currentBonus then
           -- no more valid bonuses for this category
           break
       end
       if bonusType == "multiply" then
           bonusThusFar = bonusThusFar*((currentBonus-1)*bonusDiscount+1)
       elseif bonusType =="multiplypercent" then
           bonusThusFar = bonusThusFar*((currentBonus-100)*bonusDiscount+1)
       else
           bonusThusFar = bonusThusFar+currentBonus*bonusDiscount
       end
       bonusDiscount = bonusDiscount*bonusCategory.nextBonusValue
   end
   return bonusThusFar
end
local function categoryAttackBonus(activeUnit,categoryAttackBonusTable,defaultAttackTable)
   local bonusInfoTable = categoryAttackBonusTable[activeUnit.type.id]
   if not bonusInfoTable then
       -- unit doesn't get a bonus
       return
   end
   -- keep track of bonuses already computed
   local bonusResultsTable = {}
   -- keep track of units that have already been "used" for a bonus
   local usedUnitTable = {[activeUnit.id]=true}
   for index,bonusCategory in pairs(bonusInfoTable) do
       bonusResultsTable[index]=computeCategoryBonus(activeUnit,categoryAttackBonusTable,bonusCategory,usedUnitTable)
   end
   -- compute the bonus
   local bonusType = string.lower(categoryAttackBonusTable.type)
   local resultAttackBeforeRounding = nil
   if bonusType == "multiply" then
       local bonus = 1
       for __,bonusResult in pairs(bonusResultsTable) do
           bonus = bonus*bonusResult
       end
       resultAttackBeforeRounding = activeUnit.type.attack*bonus
   elseif bonusType == "multiplypercent" then
       local bonus = 1
       for __,bonusResult in pairs(bonusResultsTable) do
           bonus = bonus*bonusResult/100
       end
       resultAttackBeforeRounding = activeUnit.type.attack*bonus
   elseif bonusType == "addbonus" then
       local bonus = 0
       for __,bonusResult in pairs(bonusResultsTable) do
           bonus = bonus+bonusResult
       end
       resultAttackBeforeRounding =activeUnit.type.attack+bonus
   elseif bonusType == "addfraction" then
       local bonus = 0
       for __,bonusResult in pairs(bonusResultsTable) do
           bonus = bonus+bonusResult
       end
       resultAttackBeforeRounding =activeUnit.type.attack*(1+bonus)
   elseif bonusType == "addpercent" then
       local bonus = 0
       for __,bonusResult in pairs(bonusResultsTable) do
           bonus = bonus+(bonusResult/100)
       end
       resultAttackBeforeRounding =activeUnit.type.attack*(1+bonus)
   else
       error("categoryAttackBonus: categoryAttackBonusTable.type is not a valid value.")
   end
   -- apply the bonus and round as appropriate
   local roundType = categoryAttackBonusTable.round
   if type(roundType) ~= "string" then
       activeUnit.type.attack = math.floor(resultAttackBeforeRounding+0.5)
       return
   elseif roundType == "up" then
       activeUnit.type.attack = math.ceil(resultAttackBeforeRounding)
       return
   elseif roundType == "down" then
       activeUnit.type.attack = math.floor(resultAttackBeforeRounding)
       return
   else
       activeUnit.type.attack = math.floor(resultAttackBeforeRounding+0.5)
       return
   end
end

The two helper functions for computeCategoryBonus are only used once each in the code, so making them helper functions does not help us re-use code. However, what it does do is to break down the function design process into smaller tasks, which are each more manageable to complete. If you're trying to keep track of too many things in your program, try writing a few helper functions to handle individual tasks. We'll look at the helper functions when we actually arrive at them in the code.

GetStartedWithLuaLesson6-09-CategoryAtkBonus1.jpg

If there is no entry in the categoryAttackBonusTable or the active unit's type, then the function returns without doing anything further.

The bonusResultsTable keeps track of the total bonus from each category, so they can be added or multiplied together after all the category bonuses are applied. The usedUnitsTable keeps track of units that are no longer eligible to contribute to a bonus (such as if they have already contributed), and we initialize it with the active unit, so that it doesn't provide a bonus to itself.

The for loop computes the bonus for every category, and stores the value in the bonusResultsTable, for use later.Now, let us look at the helper functions.

First up is findBestBonusInUnitStack

GetStartedWithLuaLesson6-10-CategoryAtkBonus2.jpg

This basically does what it says in the name. For a bonusCategory, it finds the unit in the tile that provides the best bonus, and has not already provided a bonus elsewhere. If there is no qualifying unit, false is returned. If there is, that unit's bonus is returned, and the unit's id is entered into the usedUnitTable, so that unit can't provide another bonus for the active unit. We've searched for the best of something before, so there is little need to go over it again.

Next is computeCategoryBonus

GetStartedWithLuaLesson6-11-CategoryAtkBonus3.jpg

Here, we're computing the bonus for the entire bonusCategory. We need the bonus type in order to know what kind of math to do. The math itself is not very interesting. We also initialize the bonusDiscount, which keeps track of the fraction of the next unit's bonus that will actually be applied.

Next, we enter a for loop that will run one time for each possible bonus that there can be in a bonus category. If bonusCategory.maxBonusUnits is nil, then we set an arbitrary limit of 10000 units, which is very unlikely to happen in practice.

At each iteration of the loop, the best available bonus in the stack is found. If no unit was eligible to provide a bonus, then the current bonus is false, and the "break" command is issued. Break immediately exits the current loop. So, our program doesn't run 10000 loops if there is no maximum number of bonus units, it just breaks at an appropriate time.

After the bonus is found, it is added to or multiplied with the bonus already found. The bonus computed in this way will be the largest possible, since the bonus provided does not depend on the other units chosen (so we don't have a situation where "catapult" and "rock" are both useless, but "catapult" and "rock" chosen together are best). Also, since the best bonus is chosen first, the smallest bonus discount applies to it, so the cost imposed by the discounting is smallest.

Finally, we update the bonusDisount. Here, I notice a problem in the code as written, since bonusCategory.nextBonusValue is allowed to be nil. Replace

bonusDiscount = bonusDiscount*bonusCategory.nextBonusValue

with

bonusDiscount = bonusDiscount*(bonusCategory.nextBonusValue or 1) 

so that if there is no nextBonusValue, a default of 1 is used, and the bonusDiscount stays at 1 (i.e. no discount is applied).

We return to categoryAttackBonus to see

GetStartedWithLuaLesson6-12-CategoryAtkBonus4.jpg

From here, we combine the bonuses stored in bonusResultsTable, the exact way of doing so being dependant on the type of bonus involved. The math itself isn't very interesting, and doesn't seem all that likely to come up in this exact form elsewhere. If you need help with the mathematical details of some event you want to write, ask in the forums. There's a good chance someone has the skills to help you solve your specific problem.

For every type of bonus, the result is stored in resultAttackBeforeRounding, so that variable can be rounded up or down as specified by the rounding type.

Here, I notice another error. We should restrict the attack value for a unit to not exceed 98. We can do that with a math.min(value, 98).

Now, we need to test the various aspects of the attack bonus program, so we need an appropriate table

categoryAttackBonusTable = {}
categoryAttackBonusTable.type="addbonus"
local cavalryBonusCategory = {[unitAliases.horsemen.id]=2,[unitAliases.chariot.id]=4,nextBonusValue=0.5,maxBonusUnits=3}
local rangeBonusCategory = {[unitAliases.archer.id]=2,[unitAliases.musketeer.id]=4,nextBonusValue=0.75,}
categoryAttackBonusTable[unitAliases.legion.id]={cavalryBonusCategory,rangeBonusCategory}

Also, comment out the old attack bonus line, and put in a new one

attackBonus.categoryAttackBonus(unit,categoryAttackBonusTable,defaultAttackValueTable)

Expected Test Results when type is "addbonus"

Legion:4attack
Legion+chariot:8 attack
Legion+chariot+chariot: 10 attack
Legion+chariot+chariot+chariot: 11 attack
Legion+chariot+chariot+chariot+chariot+horseman: 11 (if maxBonusUnits not applied,11.5+2/16 rounds to 12)
Legion+musketeer+archer: 4+4+1.5=9.5 round to 10, (if archer computed first then 4+2+3 = 9 instead)
Legion+musketeer+chariot+chariot+chariot: 15 attack

Already, we find a problem. Although there were no syntax errors to fix, I forgot to reset unit attack values. So, very quickly, the legions keep gathering extra attack values when they are activated (since the base attack is not reset to 4). Since this is happening anyway, I'll test the 98 attack value maximum. That appears to work, at least in the case of no specified rounding.

Cut and paste the appropriate lines from the simpleAttackBonus to the categoryAttackBonus, and save.

With that bug fixed, everything works as expected.

Next, we change the categoryAttackBonusTable to

categoryAttackBonusTable = {}
categoryAttackBonusTable.type="addpercent"
local cavalryBonusCategory = {[unitAliases.horsemen.id]=50,[unitAliases.chariot.id]=100,nextBonusValue=0.5,maxBonusUnits=3}
local rangeBonusCategory = {[unitAliases.archer.id]=50,[unitAliases.musketeer.id]=100,nextBonusValue=0.75,}
categoryAttackBonusTable[unitAliases.legion.id]={cavalryBonusCategory,rangeBonusCategory}

Expected Test Results when type is "addpercent". These are the same, since adding we're adding percentages of the same base value of 4. So a value of 2 is the same as adding 50% of 4.

Legion:4attack
Legion+chariot:8 attack
Legion+chariot+chariot: 10 attack
Legion+chariot+chariot+chariot: 11 attack
Legion+chariot+chariot+chariot+chariot+horseman: 11 (if maxBonusUnits not applied,11.5+2/16 rounds to 12)
Legion+musketeer+archer: 4+4+1.5=9.5 round to 10, (if archer computed first then 4+2+3 = 9 instead)
Legion+musketeer+chariot+chariot+chariot: 15 attack

The results are the same.

Similarly,

categoryAttackBonusTable = {}
categoryAttackBonusTable.type="addfraction"
local cavalryBonusCategory = {[unitAliases.horsemen.id]=.50,[unitAliases.chariot.id]=1.00,nextBonusValue=0.5,maxBonusUnits=3}
local rangeBonusCategory = {[unitAliases.archer.id]=.50,[unitAliases.musketeer.id]=1.00,nextBonusValue=0.75,}
categoryAttackBonusTable[unitAliases.legion.id]={cavalryBonusCategory,rangeBonusCategory}

Legion:4attack
Legion+chariot:8 attack
Legion+chariot+chariot: 10 attack
Legion+chariot+chariot+chariot: 11 attack
Legion+chariot+chariot+chariot+chariot+horseman: 11 (if maxBonusUnits not applied,11.5+2/16 rounds to 12)
Legion+musketeer+archer: 4+4+1.5=9.5 round to 10, (if archer computed first then 4+2+3 = 9 instead)
Legion+musketeer+chariot+chariot+chariot: 15 attack

We'll have to do some new calculations, now, since multiply and multiplyfraction give different results than the various adding formulations.

categoryAttackBonusTable = {}
categoryAttackBonusTable.type="multiply"
local cavalryBonusCategory = {[unitAliases.horsemen.id]=1.50,[unitAliases.chariot.id]=2.00,nextBonusValue=0.5,maxBonusUnits=3}
local rangeBonusCategory = {[unitAliases.archer.id]=1.50,[unitAliases.musketeer.id]=2.00,nextBonusValue=0.75,}
categoryAttackBonusTable[unitAliases.legion.id]={cavalryBonusCategory,rangeBonusCategory}
categoryAttackBonusTable.round="down"

Legion:4attack
Legion+chariot:8 attack
Legion+chariot+chariot: 12 attack
Legion+chariot+chariot+chariot: 15 attack
Legion+chariot+chariot+chariot+chariot+horseman: 15 (if maxBonusUnits not applied,~17 )
Legion+musketeer+archer: 4*2*(1.375) = 11 , (if archer computed first then 4*1.5*1.75 =10.5 instead, so use round down)
Legion+musketeer+chariot+chariot+chariot: 30 attack (4*2*2*1.5*1.25)

Finally, we use multiplyPercent, and check for the same results

categoryAttackBonusTable = {}
categoryAttackBonusTable.type="multiplypercent"
local cavalryBonusCategory = {[unitAliases.horsemen.id]=150,[unitAliases.chariot.id]=200,nextBonusValue=0.5,maxBonusUnits=3}
local rangeBonusCategory = {[unitAliases.archer.id]=150,[unitAliases.musketeer.id]=200,nextBonusValue=0.75,}
categoryAttackBonusTable[unitAliases.legion.id]={cavalryBonusCategory,rangeBonusCategory}
categoryAttackBonusTable.round="down"

Legion:4attack
Legion+chariot:8 attack
Legion+chariot+chariot: 12 attack
Legion+chariot+chariot+chariot: 15 attack
Legion+chariot+chariot+chariot+chariot+horseman: 15 (if maxBonusUnits not applied,~17 )
Legion+musketeer+archer: 4*2*(1.375) = 11 , (if archer computed first then 4*1.5*1.75 =10.5 instead, so use round down)
Legion+musketeer+chariot+chariot+chariot: 30 attack (4*2*2*1.5*1.25)

Quickly, I find that if a bonus applies, that bonus makes the attack value of a legion 98. I failed to multiply percents correctly in computeCategoryBonus. The Line

bonusThusFar = bonusThusFar*((currentBonus-100)*bonusDiscount+1)

Is changed to

bonusThusFar = bonusThusFar*((currentBonus-100)*bonusDiscount+100)/100

Finally, Change archer bonus to 110 and musketeer bonus to 120 (so a unit will make a legion have 4.4 and 4.8 value before rounding), and test the rounding options. You will find they all work.

At the moment, we don't have a way to mix different types of bonuses. As said earlier, we might want this so that some units can be better at giving bonuses to low attack units than to high attack units, while others give a percentage bonus to everything. We could now write a bonus system that allows for multiple types of bonuses. However, the current bonus system allows for the bonus to change per unit. This means that we can, for example, convert a +2 attack bonus to a plus 100% bonus for a unit with 2 attack, and a plus 50% bonus for a unit with 4 attack. In the case of the multiplied bonuses, this will make the bonus more powerful if there is more than one. That is, 2 attack, +2 twice is 6, but if +2 is changed to x2, then the multiplied bonus brings the attack up to 8. However, this seems like it would be a relatively unlikely problem, and there are other things this code can't handle, so we'll let someone else make the code if they actually need it.

So, what we'll do is write a function that will change all appropriate entries in the categoryAttackBonusTable to reflect a different value. Hence,

-- plusFixed(unitType,number,categoryAttackBonusTable) --> void
-- changes the bonus given by unit type to any unit to be equivalent
-- to adding number to that unit's attack power

Here, an issue with tables arises. It is very likely that when someone defines their cateogryAttackBonusTable, they'll define it something like this

local rangeBonusCategory = {[unitAliases.archer.id]=.25,[unitAliases.musketeer.id]=.5}
local bonusTableForInfantry = {rangeBonusCategory}
local cABT = {type="addfraction"}
cABT[unitAliases.phalanx.id]=bonusTableForInfantry
cABT[unitAliases.legion.id] = bonusTableForInfantry

However, this means that both cABT[unitAliases.legion.id] and cABT[unitAliases.phalanx.id] actually refer to the same piece of memory, and changing cABT[unitAliases.legion.id][1][unitAliases.archer.id] will also change the value at cABT[unitAliases.phalanx.id][1][unitAliases.archer.id]. This is because of the way lua tables are designed. This design can be useful, for example it allowed us to make changes to usedUnitsTable in the last function without having to copy the table, make changes to the copy of the table, and then replace the original table with that copy as a return value. However, it also requires us to be careful when we make changes to entries in tables. In this case, we'll simply copy the table and all its sub values into a new table.

The code to do this is given by

-- duplicateTable(table or value) --> table or value
-- duplicates a table and all the other tables referenced in that table
-- this way, a change can be made to any element of the table,
-- and it won't impact data referenced anywhere else (unless this newly
-- created table is subsequently referenced)
-- if a non-table value is input, that value is returned
--
local function duplicateTable(input)
    if type(input) == "table" then
        -- the input value is a table, so create a new table
       local duplicatedTable={} 
        -- and copy all the indexes and values into the new table, 
        -- making sure to duplicate any tables that appear as values
        for index,value in pairs(input) do
            duplicatedTable[index]=duplicateTable(value)
        end
    else
        -- the input value was not a table, so we return that value
        return input
    end
end

We will explain this code in the next section, since it involves a programming technique which we haven't discussed before.

For now, we analyze plusFixed:

-- plusFixed({[unitType1.id]=fixedBonus,[unitType2.id]=fixedBonus},categoryAttackBonusTable) --> void
-- changes the bonus given by unitTypeI.id to a unit to be equivalent to
-- to adding number to that unit's attack power.  If the unit doesn't receive a bonus
-- from the unitType, then it won't after applying plusFixed.
-- The value of the existing bonus is ignored.

local function plusFixed(fixedUnitBonusTable,categoryAttackBonusTable)
    local bonusType = string.lower(categoryAttackBonusTable.type)
    for bonusReceivingUnitTypeID,bonusTable in pairs(categoryAttackBonusTable) do
        -- replace bonusTable with an equivalent table that is not referened
        -- anywhere else, so that changes to it can be made without impacting
        -- data elsewhere
        categoryAttackBonusTable[bonusReceivingUnitTypeID] = duplicateTable(bonusTable)
        -- make bonusTable refer to the "current" bonus table for the unit type
        bonusTable =categoryAttackBonusTable[bonusReceivingUnitTypeID] 
        -- For each unit type in the fixedUnitBonusTable, check if any bonus category
        -- in the bonusTable corresponds to it.  If so, make the change
        for bonusGivingUnitTypeID,fixedBonusValue in pairs(fixedUnitBonusTable) do
            -- get the attack of the unit type 
            local baseAttack = civ.getUnitType(bonusReceivingUnitTypeID).attack
            -- in each bonus category, check if the giver's unit type id is there,
            -- if so, change to the correct attack
            for bonusCategoryIndex,bonusCategory in pairs(bonusTable) do
                if bonusCategory[bonusGivingUnitTypeID] then
                -- change the bonus appropriately
                    if bonusType == "addbonus" then
                        bonusCategory[bonusGivingUnitTypeID]=fixedBonusValue
                    elseif bonusType == "addpercent" then
                        bonusCategory[bonusGivingUnitTypeID]=100*fixedBonusValue/baseAttack
                    elseif bonusType == "addfraction" then
                        bonusCategory[bonusGivingUnitTypeID]=fixedBonusValue/baseAttack
                    elseif bonusType == "multiplypercent" then
                        bonusCategory[bonusGivingUnitTypeID]=100*(fixedBonusValue+baseAttack)/baseAttack
                    elseif bonusType == "multiply" then
                        bonusCategory[bonusGivingUnitTypeID]=(fixedBonusValue+baseAttack)/baseAttach
                    else
                        error("pluxFixed: categoryAttackBonusTable.type does not have an acceptable value.")
                    end
                end
            end
        end
    end
end

GetStartedWithLuaLesson6-13-plusFixed1.jpg

As before, we save the bonusType in lower case, since we'll be using it a lot later. Next, we open a for loop, where the index is the id of the unit type receiving the bonus, and the value is the bonus table for that unit type. Next, we duplicate the bonus table, so that we have a table we can change without impacting anything elsewhere, and we store the duplicated table in categoryAttackBonusTable in place of the original bonusTable for the current bonus receiving unit id. Next, we assign this duplicated table to the variable bonusTable, so we can refer to it later in the code.

GetStartedWithLuaLesson6-14-plusFixed2.jpg

We now perform the following actions for each unitType.id and corresponding fixedBonusValue submitted in the fixedUnitBonusTable.

First, get the attack of the bonus receiving unit, since we'll need that in order to customize the bonus. Next, check each bonus category in the bonus table. If the bonus category has the current unitTypeID as a key, then replace the existing value with a bonus equivalent to adding a fixed value to the receiving unit's attack.

That's all that must be done, so end all the loops.

Now we test the function.

Add unitAliases.phalanx = civ.getUnitType(3) to the appropriate file.

defaultAttackValueTable[unitAliases.phalanx.id]=1
categoryAttackBonusTable = {}
categoryAttackBonusTable.type="multiplypercent"
local cavalryBonusCategory = {[unitAliases.horsemen.id]=150,[unitAliases.chariot.id]=200,
								nextBonusValue=0.5,maxBonusUnits=1}
local rangeBonusCategory = {[unitAliases.archer.id]=125,[unitAliases.musketeer.id]=150,
								nextBonusValue=0.75,maxBonusUnits=1}
local standardBonus = {cavalryBonusCategory,rangeBonusCategory}
categoryAttackBonusTable[unitAliases.legion.id]=standardBonus
categoryAttackBonusTable[unitAliases.phalanx.id]=standardBonus
categoryAttackBonusTable.round="up"
attackBonus.plusFixed({[unitAliases.chariot.id]=2,[unitAliases.musketeer.id]=2},
						categoryAttackBonusTable)

The chariot and the musketeer individually should each add 2 to both a phalanx and a legion, so the phalanx should have an attack of 3, and the legion should have an attack of 6, if one bonus is applied.

Since the bonus type is multiplypercent, together the phalanx would have an attack of 9, and the legion will also have an attack of 9.

In testing, we quickly get this error You should see this in lua console if this worked ...rive_c\Test of Time\Scenario\ClassicRome\attackBonus.lua:356: bad argument #1 to 'getUnitType' (number expected, got string) stack traceback: [C]: in function 'civ.getUnitType' ...rive_c\Test of Time\Scenario\ClassicRome\attackBonus.lua:356: in function 'attackBonus.plusFixed' ...e15\drive_c\Test of Time\Scenario\ClassicRome\events.lua:218: in main chunk

At line 356, which is

local baseAttack = civ.getUnitType(bonusReceivingUnitTypeID).attack

This must mean that bonusReceivingUnitTypeID is not a number. This is because we're looping over categoryAttackBonusTable, and that table has a couple of entries that are not integers ("type" and "round"). Therefore, we have to insert code to deal with this eventuality.

The simplest way is to put everything in the loop body into an if statement, so that it only runs when bonusGivingUnitTypeID is actually a number. You should be able to do this without detailed instructions, so do so (hint: use type function).

The next error is

...rive_c\Test of Time\Scenario\ClassicRome\attackBonus.lua:360: bad argument #1 to 'pairs' (table expected, got nil)
stack traceback:
	[C]: in function 'pairs'
	...rive_c\Test of Time\Scenario\ClassicRome\attackBonus.lua:360: in function 'attackBonus.plusFixed'
	...e15\drive_c\Test of Time\Scenario\ClassicRome\events.lua:218: in main chunk

360 for bonusCategoryIndex,bonusCategory in pairs(bonusTable) do

Here, we have a nil value for bonusTable. This turns out to be an error in duplicateTable. When I duplicated the table, I forgot to return that table. That is, both the if and else statement in that function need a return command, and I forgot to add return duplicatedTable in the if portion. Add this to the duplicate table function.

After this, our predictions are true. Testing the other types of bonus is left as an assignment. You can find the completed code here.

Recursive Functions

Let us return to the duplicateTable function. Recall that in the last section we added a line to it, so we'll restate it here:

-- duplicateTable(table or value) --> table or value
-- duplicates a table and all the other tables referenced in that table
-- this way, a change can be made to any element of the table,
-- and it won't impact data referenced anywhere else (unless this newly
-- created table is subsequently referenced)
-- if a non-table value is input, that value is returned
--
local function duplicateTable(input)
    if type(input) == "table" then
        -- the input value is a table, so create a new table
       local duplicatedTable={} 
        -- and copy all the indexes and values into the new table, 
        -- making sure to duplicate any tables that appear as values
        for index,value in pairs(input) do
            duplicatedTable[index]=duplicateTable(value)
        end
		return duplicatedTable
    else
        -- the input value was not a table, so we return that value
        return input
    end
end

GetStartedWithLuaLesson6-16-duplicateTable.jpg

The interesting thing about duplicateTable is that it invokes duplicateTable within its code. When we copy the key-value pairs into the new table, we run duplicateTable on the value. If the value is not a table, then we are in the else case, and the value is immediately returned to be inserted into the duplicated table. If the value is a table, that table is duplicated before anything else is done.

Let us look at a simple example of a recursive function, the factorial function N!. In lesson 3, we discussed the factorial function in the context of loops. Since N!=N*(N-1)*(N-2)*...*3*2*1, we can give another definition of N!:

N!=1 if N = 1
N!=N*(N-1)! if N > 1

Or, using the convention that 0!=1, we can state

N!=1 if N = 0
N!=N*(N-1)! if N > 0

The case where N =1 or N=0 is called the "base case." If we keep substituting N! as N*(N-1)!, then we won't go on forever, because we will eventually reach a point where N=0, and N! can be substituted as 1 instead of N*(N-1)!. As long as there is a base case, our program will eventually reach the base case(s), and begin "completing" evaluation.

function factorial(N)
if N>0 then
    return N*factorial(N-1)
else
    return 1
end
end
print(factorial(5))
print(factorial(6))

Run this in code in the lua demo website, or using the TOTPP lua interpreter.

Returning to our duplicateTable function, the base case is any time a table value is not itself a table. When that happens, the recursion "chain" ends, and everything can evaluated.

Although is shouldn't be a problem in this particular case, it should be noted that it is possible for there to not be a "base case" in the recursion style: if table, recur, if not return a value. Consider the following table system

a={}
b={[1]=a}
a[1]=b

In this case, the code would never end.

It is possible that even if the code could "theoretically" execute to completion, you run out of memory before that. This is called a "stack overflow," and if it occurs, you will have to think of an alternate way to solve your problem. It will probably be best to ask for help in that case (and it might be useful to provide others with an example of a problem that results in a stack overflow), but you might also look into "tail recursion", including http://www.lua.org/pil/6.3.html .

Conclusion

With the skills covered in this chapter, you now have the tools to split a large events file into multiple files that are more manageable. You also have the basics for how to write re-useable modules for your events, so that your work can be used by others. Remember that if someone else uses your implementation of a feature, they will spend much less time coding and testing it, allowing them to either finish the scenario sooner, or to introduce some other feature. So, it might very well pay to spend at least a little effort making your code into a reusable module. Perhaps you have an idea for a cool scenario mechanic, but don't have a scenario for it (or don't want to take the time to make it). If that's the case, just make the module and test it in a "toy" scenario (like Classic Rome) so that it can be used more or less "off the shelf" when someone needs it.