Welcome (back) to the third part of my tutorial Become a Software Engineer by modding Fallout 3. In this post I will explain how to add the missing functionality, of rewarding the Player with an additional perk whenever he levels up, to the Extra Perk Quest Script. Adding that behavior will cause some unintended and unwanted side-effects, we have to address later on. There will be many possible solutions to choose from, of how to compensate those side-effects. A good solution has to consider behavior, potentially introduced by other mods, which we can only assume. Finding the proper trade-off between correctness and efficiency will be the challenge in selecting a good solution. Scripting, awareness, reasoning and algorithms are the major topics of this post.
I expect you already know what I explained in Part 1 and Part 2 of this tutorial. If you intend to implement the Extra Perk by yourself, you have to catch up to the point where Part 2 of the tutorial ends.
Detecting if the Player leveled up
The way we had to implement tracking, as defined by the Extra Perk Quest Script, is called polling. Instead of getting notified when an event of interest happened, we have to check actively and repeatedly if an event occurred.
To detect if the Player leveled up, we have to remember the level he had
before we ran the check. Remembering something means adding a
variable to the
Extra Perk Quest Script. The statement
at line 1:
int
defines an integer variable playerLevel
, which
we use to store the last player level, already handled by the script.
Whenever the level of the Player is greater than the
level stored in playerLevel
, he leveled up
between the current and the previous check. The statements defined by the conditional block,
demarcated by lines 4:
if
and 6:
endif
, will be
executed only, if the condition
player.
getLevel
>
playerLevel
is true
. Besides doing something useful, we have
to avoid rewarding the Player twice. Therefore we will reflect the
detection and handling of the Player by increasing the value of
playerLevel
by 1
(as defined by line 5:
set
).
Start the G.E.C.K.
and add the new lines to the QWEExtraPerkQuestScript
. Make the
script look like the screenshot to the right.
After adding the new lines, read the whole script once again. Is
the condition defined by the second conditional block still proper in terms of
our requirements? In my opinion it is not, after the introduction of the
variable playerLevel
. It should
be rephrased as: stop the Extra Perk Quest Script
when the Player has been rewarded with an additional perk for the maximum
player level instead of: stop the
Extra Perk Quest Script when the Player
has leveled up to the maximum player level. It may seem to be a tiny
difference, but some subtle bugs (which are the most difficult to find) stem
from this kind of differences. Hence we will change the condition of the
second conditional block from
player.
getLevel
>=
maxPlayerLevel
to
playerLevel
>=
maxPlayerLevel
.
I suggest you always read the whole script for which you
introduced a new variable, as well as all scripts
referencing any variable defined by that script, once again. The
likeliness of find potential issues is high when inspecting
scripts immediately after altering them.
Change the condition of the second conditional block in the
G.E.C.K. as described above. What's left is to initialize the variable
playerLevel
with the current level of the
Player, at the moment he picks the Extra Perk.
Select the stage with an index of 0
from the
QWEExtraPerkQuest
and add the statement
set
playerLevel
to
player.
getLevel
to its embedded script (the function acting as the quest
initializer
). Don't forget to save
all changes made to the Extra Perk.esp
.
Let's test the new behavior by starting Fallout 3 and loading a
save-game where you have not already obtained the
Extra Perk. I guess that Fallout 3 treats a
save-game like a mod, placed last in the loading order. Hence objects
contained in the save-game will override previously loaded objects. A
save-game containing an old version of the QWEExtraPerkQuest
would be associated with an old version of the
Extra Perk Quest Script, not defining
the newly introduced variable
playerLevel
. It's only a guess, but the effect
is as described.
Open the in-game console and submit the command:
advancePCLevel
.
The Level-Up-Dialogue should be displayed. Distribute the skill points and pick the
Extra Perk. Open the in-game console again and submit the
command:
showQuestVars QWEExtraPerkQuest
.
The variable playerLevel
should
be displayed, having a value equal to the current Player's level, which means
the quest initializer
works as intended. Now set the Player's
level to the same value assigned to the variable
maxPlayerLevel
by submitting the command:
player.setLevel <level>
.
You should be able to observe that the variable
playerLevel
gets incremented by 1
every 10
seconds (the delay between two runs of the
QWEExtraPerkQuestScript
as defined by the
QWEExtraPerkQuest
). As soon as the value of the
variable playerLevel
is equal to
the value of the variable
maxPlayerLevel
the
QWEExtraPerkQuest
will be stopped. This behavior would not be
observable if we didn't changed the condition of the last conditional block
from
player.
getLevel
>=
maxPlayerLevel
to
playerLevel
>=
maxPlayerLevel
(you may try it).
Rewarding the Player with an additional perk
Finally everything is in place. We are ready to add the code necessary to reward the Player with an additional perk. But neither the G.E.C.K. nor FOSE provide a function to display the Perk-Selection-Dialogue. Hence we will level the Player up to force the Perk-Selection-Dialogue to be displayed as a side-effect. Afterwards we have to restore the Player's level to the level he had before.
How to level the Player up we already know (we submitted the command
advancePCLevel
many times using the in-game console). And how to set the Player's level to a
specific value we know as well (we also submitted the command
player.setLevel
before). But there is one thing we have to consider: what if the Player
leveled up to the maximum player level? Is it possible and safe to level him
up once more? Decreasing the Player's current level first and leveling him
up afterwards seems to be the better solution. Detecting that the Player
leveled up implies he had a lower level before. Hence decreasing the current
Player's level is always safe.
It is very important to be aware of the boundaries a variable
defines for its value to be valid. The type of a
variable defines the widest boundaries possible for a
variable, but most often not all values inside those boundaries are
valid. In our case only
player levels between 1
and maxPlayerLevel
inclusively are valid values for the variable
playerLevel
. Whenever we introduce a statement which
assigns a value to a variable, we should ask ourselves:
- Are we aware of the boundaries defined by the variable?
- Does the new value respect the boundaries defined by the variable?
- Where does the new value come from? Who calculated the new value?
- Is it possible to verify that the calculated value is inside the boundaries?
Questioning the code you just created does reduce your productivity. But of which value is code, you are not confident about its correctness? My preferred way of adding new code is using the Test First approach. But this is not possible when modding Fallout 3 (besides porting the whole functionality to a language supporting Test First). So if you observe that debugging does follow your coding often, slow down your coding. Take your time and think more deeply about what you want to achieve before putting your fingers onto the keyboard again. Your productivity should increase.
Start the G.E.C.K., load the Extra Perk and alter the
first conditional block as shown above. Don't forget to define the
variable previousLevel
as well. Save
your
changes and start Fallout 3. Load a proper save-game. Follow my
instructions carefully to ensure, you will observe what I intend you to
observe:
- Open the Pipboy and find the information on top of the screen, displaying the Player's current experience points (620 in this example) together with the experience points the Player requires to level up (1050 in this example). Calculate the difference, the amount of experience points required to level up (430 in this example), and write down the experience points required to level up (1050 in this example). Leave the Pipboy.
- Enter the in-game console and submit the command:
rewardXP
<difference>. Leave the in-game console. - The Player should level up. Distribute the skill points and pick the Extra Perk.
- Open the Pipboy again and inspect the current experience points of the Player. They should be equal to the experience points he required to level up (as written down in step 1). Calculate the difference and remember the experience points required to level up once again. Leave the Pipboy.
- Enter the in-game console and level the Player up as described in step 2, using the newly calculated difference. Leave the in-game console.
The Level-Up-Dialogue should be displayed. After distributing the skill points
and picking another perk, wait at most 10
seconds. The
Level-Up-Dialogue should show up. If that happens, the
Extra Perk does something we expected it to do. But after
distributing the skill points (wait! we didn't intend to reward the Player
with more skill points, did we?) and picking the additional perk, open the
Pipboy and inspect the current experience points the
Player has. They should be different from what you have written down.
Our current solution of how to reward the Player with an additional perk causes two side-effects, we are not interested in:
- The Player's current experience points are modified.
- The Player is rewarded with additional skill points.
Compensating missing experience points
On solution to adjust the modified experience points would be to reset its value to the value it had before the Extra Perk rewarded the Player with an additional perk. The G.E.C.K. provides a function
setActorValue
taking two input parameters: the
state to be
modified and the value to set the state to. For example, this function works
well for all S.P.E.C.I.A.L stats, but sadly it does not for the
XP state.
Another possible solution would be to add the difference between the experience points the Player had before the modification occurred and the experience points he currently has to his current experience points, using the function rewardXP. This is an option, because the modification always results in a reduction of the Player's experience points.
To reward the Player with an additional perk while preserving his experience points, we have to do something like this:
- Remember the Player's experience points
- Reward the Player with an additional perk
- Calculate the missing experience points
- Reward the player with the calculated difference
The requirement defined by step 2 is already implemented in terms of three statements, as defined by lines 06: - 08:
of the QWEExtraPerkQuestScript
.
The complete algorithm would consist of six statements, if we were able to
formulate the requirements defined by each of the remaining three steps by a single statement. All this statements together implement the high-level functionality of rewarding the
Player with an additional perk.
The four steps formulated above are at different levels of abstraction. Step 1 is a technical prerequisite, required to calculate the difference between the experience points done in step 3. Step 2 is at the same level of abstraction as the level we formulated the requirements of the Extra Perk. And step 4 is at a lower level of abstraction than step 2, but at the same level as step 1 and 3.
If we would put all those statements into the conditional block, demarcated by
the lines 05:
if
and 10:
endif
,
it would be hard to recognize their higher level semantic. Moreover we would
mix up different level of abstraction the QWEExtraPerkQuestScript
would be composed of. Keeping all statements of a script at the same
level of abstraction makes it more readable and hence more maintainable.
The complexity of a piece of software increases with the amount of responsibilities it has. Higher complexity always bears the risk of more hidden bugs. Thus we should strive to reduce the number of responsibilities for one piece of software whenever possible. But there is no simple rule when more than one responsibility is appropriate; it's a matter of good design.
Motivated by the reasoning and advices given, we will encapsulate the whole functionality, as defined by steps 1 - 4, by a separate script, associated with a quest stage (misusing the concept to implement something similar to a function).
Start the G.E.C.K. and load the Extra Perk.esp
.
Find the QWEExtraPerkQuest
and open the edit dialogue. Select
the Quest Stages tab and create a
new Quest Stage with an index of 100. Add a new
Log Entry and edit its associated script (as we did
for the Quest Stage acting as the quest initializer) by adding
all the statements shown in the script below. Let's have a closer
look at what the script does:
Line 05:
set
stores the experience points the Player has before we reward him with an
additional perk, in the local variable
targetXP
. The statements of lines
06: - 08:
are equal to the statements of
the same lines defined by the QWEExtraPerkQuestScript
. Together
they reward the Player with more skill points and an additional perk, while
reducing his experience points. Line
09:
set
calculates the missing experience points and stores them into the local
variable diffXP
. Line 10:
rewards the Player with the amount of experience points he lost, as stored in the variable
diffXP
.
If the G.E.C.K. would support naming a quest stage,
we would name the stage with index 100 something like
rewardPerk
. To keep the tutorial more
expressive, I will refer to the quest stage with index 100 using
that name.
What's left is to call the quest stage function
rewardPerk
every time when we detect that the
Player leveled up. Find the QWEExtraPerkQuestScript
and make it
look like the image to the left.
Now all statements of the script are at the same level of
abstraction. The first conditional block (demarcated by the lines
07: - 10:
) handles when and how often to reward
the Player with an additional perk. The second conditional block (demarcated
by the lines 11: - 13:
) decide when to stop
tracking the Player's level. All those statements are at a very
high level of abstraction, just one level below the level we formulated the
requirements.
If we wouldn't cache the maximum player level (which I did only to explain the
use of an initializer quest stage) by in-lining the function call
instead, the QWEExtraPerkQuestScript
would define only one
variable, the playerLevel
, tracking
the last player level we do not have to reward the Player with an additional
perk anymore. Hence the QWEExtraPerkQuestScript
has a single
responsibility (besides terminating the execution of itself).
Start Fallout 3 and test if the experience points as well as the
player level are unchanged after the Extra Perk rewarded the
Player with an additional perk. After you verified that everything works fine,
go and level up the Player once again. Surprise! The Extra Perk
won't reward the Player with additional perks any further (even so the
Extra Perk Quest is still running). The
reason why this happens is because by default a quest does not allow
a quest stage to be set repeatedly. To change that, we have to
enable the option Allow Repeated Stages for the
QWEExtraPerkQuest
(available at the Quest Data tab
of the quest dialogue), which implies that any script
associated with a quest stage is run every time the
quest is set to that stage.
I don't know why we have to decided, if a quest stage can be set
repeatedly for a whole quest instead for every
quest stage. In our case we don't want to allow setting the
quest stage with index 0 multiple times, but we have to. Open
the G.E.C.K. and enable the option
of the QWEExtraPerkQuest
. Start
Fallout 3 again and verify the Extra Perk does
reward the Player once for every level the Player gains after he picked the
perk.
Expecting the unexpected
Our current solution of how to compensate the missing experience points (caused by leveling the Player first down and afterwards up) relies on the expectation, that the Player gains the exact amount of experience points we reward him with. The vanilla Fallout 3 has a perk Swift Learner, which gives the Player 10% more experience points per rank than he is rewarded with. This means, if the Player has both perks (the Extra Perk and Swift Learner) our current solution of compensating the missing experience points would give the Player too many experience points.
To verify my prediction, start Fallout 3 and pick the Extra Perk by leveling the Player up. Calculate the amount of experience points required to level the Player up once more. Remember the total amount of experience points required before rewarding the Player with the calculated difference. When leveling up pick the Swift Learner perk and wait for the Extra Perk to reward the Player with an additional perk. Pick the Swift Learner perk again (now the Player gains 20% more experience points). Afterwards check the Player's experience points, which should be larger than the experience points you remembered before rewarding him the last time.
We could fix our solution by taking into account, if the Player has obtained the Swift Learner perk and which rank. But what if some other mod would change the rewarded experience points as well? Whenever we detect a defect in one of our algorithms, we shouldn't fix it by simply addressing the special case that revealed the defect, but by addressing the root, causing the defect. In terms of our Extra Perk this means, we have to expect that the amount of experience points rewarded to the Player may be different from the amount of experience points he gained.
Compensating the missing experience points by guessing iteratively
Our second solution has to deal with an unknown function that may modify the
amount of experience points before rewarding the Player. One thing we
know about the function, is that it grows at most linear. This means, the
resulting experience points calculated are always less than or equal to the
product of a fixed multiplier m
and the initial experience points. Or more
specific:
F(xp) = m * xp + c
.
This knowledge can be derived from the functions provided by the
G.E.C.K. to modify experience points by perks. Select the object
category Perk
and filter the list of objects by the prefix
Swift. Open the Edit-Dialogue for the Swift Learner perk
and double-click any of its Perk Entries. Inspect all available
modifications by browsing through the drop-down menu labeled Function
.
The image blow shows the new script for compensating the missing experience points after the Extra Perk leveled the Player first down and afterwards up again.
Before I start to explain the whole script in detail, I will outline its
major structure. Lines 10:
label
1
and 28:
goto
1
demarcate a loop. The condition, if to
continue executing statements inside the loop, is defined by line
11:
if
diffXP
> 0
.
If the body of the loop is not entered, the
execution of script will be terminated. The loop condition states: as long as
the difference between the experience points the Player had before the
Extra Perk did his job and the current amount of experience
points the Player has is greater than 0
, execute
statements defined by the body of the loop.
Analyzing the script in detail
Lines 01:
- 08:
define and initialize local
variables used by the script. The variable targetXP
is not required but improves the readability of the script. It could
be replaced by the alternative expression
QWEExtraPerkQuest
.
targetXP
which in my opinion is much too long. Its value is the amount of experience
points the Player had before the Extra Perk reduced them.
The
variable diffXP
defines the
missing difference of experience points between the target experience points
and the experience points the Player has at the moment of the calculation.
When this value is zero or less, the script has done its job. The
variable xp
defines the amount of
experience points to reward the Player with. Its value reflects the best guess the script made based on
information collected in the previous iteration. It gets initialized with
1
, the smallest meaningful amount of experience
points. In contrast the variable
gainedXP
holds the amount of experience points
the Player gained (which could be different from the amount of experience points
the Player was rewarded with). And the last variable
gainRatio
is the ratio between the experience
points the Player gained and the experience points the Player was rewarded
with. If the value of gainRatio
is 1, the
Player gained the same amount he was rewarded with. If it is for example 2,
the Player gained double the amount he was rewarded with. It is a hint the
script uses to predict the amount of experience points the Player
will gain when rewarded the next time with an amount of experience points.
Lines 12:
- 14:
make sure the amount of experience
points to reward the Player with is at least 1
,
the smallest meaningful amount of experience points to reward the Player
with.
Line 15:
rewards the Player with an amount of
experience points while line 16:
calculates the
gained amount of experience points (which is the difference between the
missing amount before the Player was rewarded and the missing amount
afterwards).
Now the script tries to predict the amount of experience points to
reward the Player with next, while avoiding to exceed the amount of target
experience points. To achieve this, the script relays on the fact,
that the function F(xp)
does grow at most linear. The
returned value of each such function is an integer value,
while the calculated result most likely is a floating point value. Before the
calculated result is returned, the floating point value is rounded up. We
should consider two important cases for the function
F(xp) = m * xp + c
:
- The values of the variable
xp
and of the multiplierm
are small while the value of the constantc
is large. - The values of the variable
xp
and of the multiplierm
are large while the value of the constantc
is small.
In the first case the returned value is dominated by the constant
c
which is much larger than m
(the multiplier the
script tries to find out). With increasing values of
xp
the dominance of c
dims away.
In the second case the returned value is dominated by the multiplier
m
regardless of the value of xp
.
But most importantly, in both cases the ratio between the experience points
gained (the value returned by F(xp)
) and the experience
points rewarded (the value of the variable
xp
) is always greater than the multiplier
m
the script is in search for (ignoring negative values for
constant c
). And for increasing values of
xp
the ratio becomes either smaller or stays
the same.
Lines 17:
- 25:
handle the cases when the experience points rewarded last lead to a positive
gain of experience points. In that case the script calculates the
changed amount of missing experience points (line
18:
). If that amount is larger than or equal to
the amount of experience points gained last (line
19:
), than the value for the variable
xp
is set to the remaining amount of missing
experience points diffXP
divided by the
gainRatio
. Due to the fact, that the gain
ratio will stay the same or becomes smaller, when the amount of experience
points rewarded is increased, we will not exceed the target experience
points.
Otherwise, if the missing amount of experience points is lesser than the last
gained amount of experience points, we have to decrease the amount of
experience points to reward the Player with. This could result in a larger
gain ratio value, which we do not know. We could have stored the largest value
for the gain ratio in another local variable, at the first time we
rewarded the Player with 1
experience point, but
in my opinion the increase of complexity and therefore reduction of
maintainability isn't worth the gain of a slightly faster algorithm. Hence the
script starts over to reward the Player with
1
experience point
(line 23:
) as it did when the statements of the
loop body were executed the first time (lines
12:
- 14:
).
Lines 25:
- 27:
handle the case, when rewarding the Player with a positive amount of
experience points results in a gain of zero experience points. This may happen
only, if either m
is zero or c
is negative. If both
is true, rewarding the Player with any amount of experience points always
resulting in a negative gain
(0 *
xp
is always zero and a negative c
makes the result negative). If
m
is at least a positive number (even if less than
1
) and c
is a negative number, the
result could become positive if the value of xp
is large enough. To be more specific: if
xp
> abs(c) / m
, where abs()
is a function returning the
absolute value of a given input value.
The risk taken, when doubling the value of xp
in each iteration step, is a potential raise of the amount of experience points gained
from zero to abs(c)
. This results from the following
equation:
F(xp) = m * xp + c <= 0
which is equivalent to
m * xp <= abs(c)
.
The worst case in terms of the largest possible amount of experience points
gained by the Player, which exceeds the missing amount of experience points to
compensate, is when both sides of the equation are equal and the value of
xp
is 1
.
Increasing xp
to the next larger value 2
immediately cause
F(xp)
to return a value of abs(c)
.
On the other hand, if we do not double the value in each iteration step but
increase it by 1
instead, the number of required
iterations could become very large (consider m
to be a very small
value less than 1
). We have to choose between two
evils:
- Try to make the smallest possible error by risking a very large number of iterations needed to compensate the missing experience points.
- Hope the function
F(xp)
rarely returns non positive values, and in case it does, the value ofm
as well as the value ofc
is a small numbers.
In my opinion, the second case is much more likely to be introduced by other
mods and perks. Most of the mods I know tend to
increase the amount of experience points gained or reduce them slightly by
some degree, but never eliminate them at all (setting m
to zero).
And I have never heard about a mod, removing a constant large
amount of experience points (setting c
to a large negative value).
Hence I doubled the value of xp
in each
iteration step instead of increasing it by 1
.
Wiring things together
What's left is to associate the new script with the stage
101 of the QWEExtraPerkQuest
, which we have to create first. From now
on I will refer to the script, associated with stage 101,
using the name repairXP
. In addition we have to
change the script rewardPerk
to look
like the image to the right. The variable
targetXP
, which is set in the script
rewardPerk
and used in the script
repairXP
, has to be made accessible to both of
those scripts. The G.E.C.K. does not support any mechanism
for passing values around other than using some kind of
global variables. Hence the QWEExtraPerkQuestScript
has to define the variable targetXP
.
The local variable diffXP
has been
moved to the script repairXP
, which
took over the responsibility of compensating the missing amount of experience
points. Make the script look like the image below.
After saving all changes in the G.E.C.K., start Fallout 3 and repeat the procedure described at Expecting the unexpected. This time the effect should not be observable.
You may have noticed, that sometimes the Player has one
more experience point than he had, before the Extra Perk rewarded
him with an additional perk. The reason for it is, that Fallout 3 does
round up the experience points calculated. For example, if the Player misses
1
experience point, the script
repairXP
will reward him with
1
experience point, which gets multiplied by the
Swift Learner perk by either 1.1
,
1.2
or 1.3
. The
result is a float value greater than 1
but lesser
then 2
, rounded up to 2
.
This effect can't be eliminated easily. Later in the game, gaining a few more
experience points is not very significant. Hence we avoid addressing this
effect, which would result in a much more complex script. Instead we
ignore the effect.
Eliminating the reward of additional skill points
The second side-effect caused by leveling the Player up, is the gain of
additional skill points each time the Extra Perk rewards the
Player. To avoid this behavior we create the
QWENoSkillPerk
, which sets the skill points
gained to zero. This perk we will add to the Player before the
function call advancePCLevel
(made in the script rewardXP
), and
remove it immediately afterwards.
Open the G.E.C.K. and create a new perk with an ID
QWENoSkillPerk
. Make sure the perk is not playable,
which means it is neither visible nor can be picked in the Perk-Selection-Menu.
Even so we remove the perk immediately after leveling the Player up, make the
perk hidden to document that it is intended to be used as a function only.
Add a new Perk Entry with a Rank
of 1 to the
QWENoSkillPerk
, adjusting the gained skill points
to the fixed value 0
, which effectively changes
the Level-Up-Dialogue to a Pick-Perk-Dialogue.
What's left is to add the QWENoSkillPerk
to the
Player before leveling him up and to remove it immediately afterwards.
Select the QWEExtraPerkQuest
and navigate to
the Quest Stages tab. Open the script
rewardXP
and add the lines
06:
and 08:
to make
the script look like the image below.
Save all the changes and start Fallout 3 to verify, that whenever the Extra Perk rewards the Player with an additional perk, no skill points are gained. But when the Player levels up, he is gaining skill points and has to pick one more perk.
Finally we implemented the reduced requirements as stated in Part 2 of this tutorial. We are done for now. There are more things left to be implemented in the future, in Part 4. Have a break and come back soon! I have already started to work on Part 4.
Conclusion
We added the behavior of rewarding the Player with an additional perk whenever he levels up. Our solution caused some side-effects, we had to eliminate. Because of the absence of information, we had to implement an algorithm that iteratively tried to guess how to compensate experience points, which got removed in the process of rewarding the Player. We used a perk to suppress the gain of additional skill points by dynamically adding and removing it to and from the Player.
What's left is to reduce the experience points gained when the Player has the Extra Perk. The additional perk has to be rewarded when the Player has collected at least half the experience points needed to reach the next level, instead of rewarding him immediately after he leveled up. All this is covered by the final Part 4 of my tutorial.
I hope you enjoyed this post so far. Feel free to contact me either by e-mail or leave a comment at the Fallout 3 Nexus Site. If you think this tutorial is worth to be recommended, please consider to endorse it.
Logged Off: Harmlezz