Research:Stats and Levelling
Actor dynamic stats
Actions affected | Recalculated when base attributes are modified |
Description | Health points, magicka, fatigue, and encumbrance |
Implementation status | implemented |
Analysis status | Requires verification |
HP
Initial HP
health = int(0.5 * (baseStrength + baseEndurance))
At every level-up
occurs after attributes have been increased by the level-up
bonusHP = fLevelUpHealthEndMult * baseEndurance
Magicka multiplier
The non-visible stat used to stack magicka modifiers. Affected by NPC type and the Fortify Magicka Multiplier effect.
On actor creation
if actor is the player:
magickaMultiplier = fPCbaseMagickaMult
else:
magickaMultiplier = fNPCbaseMagickaMult
When a Fortify Magicka Multiplier effect is applied
magickaMultiplier is adjusted by 0.1 * effect.magnitude
Magicka points
Every time intelligence or magicka multiplier is modified
maxMagicka = magickaMultiplier * intelligence
currentMagicka is rescaled to preserve % left
Fatigue
Recovery every frame
if currentFatigue < maxFatigue:
x = fFatigueReturnBase + fFatigueReturnMult * endurance
recover (frameTime * x) fatigue
Every time a base stat is modified
maxFatigue = strength + willpower + agility + endurance
currentFatigue is rescaled to preserve % left
Encumbrance
Every time strength is modified
maxEncumbrance = fEncumbranceStrMult * strength
Resting
Actions affected | On resting/waiting |
Description | Waiting also applies to time passed after training. Resting also applies to time passed during travel. Uses common term normalizedEncumbrance. |
Implementation status | Implemented, interruptingCreatures bug fixed, interruptAtHoursRemaining bug left in for compatibility |
Analysis status | Verified, but sleep interruption has bugs |
if resting in an exterior cell and the region has a sleep creature leveled list:
x = roll hoursRested
y = fSleepRandMod * hoursRested
if x < y:
interruptAtHoursRemaining = int(fSleepRestMod * hoursRested)
interruptingCreatures = max(1, roll iNumberCreatures)
sleep will only last (hoursRested - interruptAtHoursRemaining) hours
unless interruptAtHoursRemaining == 0, then no interruption occurs # contains bug
sleep will be interrupted with 1 creature from the region leveled list # contains bug
for each hour:
for every actor in the entire world:
if resting, not waiting:
health += 0.1 * endurance
if actor does not have magic effect Stunted Magicka:
magicka += fRestMagicMult * intelligence
x = fFatigueReturnBase + fFatigueReturnMult * (1 - normalizedEncumbrance)
x *= fEndFatigueMult * endurance
fatigue += 3600 * x
Comments
Resting allows all actors in the game to recover, not just the player. There are multiple problems with interrupted rest.
If the value of interruptAtHoursRemaining is 0, which occurs when hoursRested <= 3 with default GMSTs, then the game fails to execute the interruption encounter as the rest ends, in effect making short rests completely safe.
The code only spawns the first creature it finds in the leveled list, as well as calculating the number of creatures incorrectly. interruptingCreatures should be 1 + roll iNumberCreatures, and that number of creatures should be spawned.
Finally, the design of the interruption test has hoursRested on both sides of the test. This makes the probability of being interrupted independent of the number of hours slept, except for quantization effects.
Player levelling
Skill progress
Actions affected | On exercising a skill |
Description | |
Implementation status | Implemented |
Analysis status | Verified |
Skills have a progression metric that is increased by player actions. Each action has an associated skill gain factor present in the skill record from the ESM. The progression is usually, but not always increased by that gain factor each time an action is performed. Players in werewolf form do not gain progress at all.
On successful barter
skillGain = skill.gainFactor[0] # "Successful Bargain"
if player is selling and (finalPrice > initialOffer):
skillGain *= int(100 * (finalPrice - initialOffer) / finalPrice)
elif player is buying and (finalPrice < initialOffer):
skillGain *= int(100 * (initialOffer - finalPrice) / initialOffer)
else:
skillGain = 0
All other actions
skillGain = skill.gainFactor[action]
Testing for skill increase
progress[skill] += skillGain
progressRequirement = 1 + playerSkills[skill]
if skill in player.majorSkills:
progressRequirement *= fMajorSkillBonus
elif skill in player.minorSkills:
progressRequirement *= fMinorSkillBonus
elif skill in player.miscSkills:
progressRequirement *= fMiscSkillBonus
if skill in player.class.specialization.skills:
progressRequirement *= fSpecialSkillBonus
if int(progress[skill]) >= int(progressRequirement):
progress[skill] = 0
playerSkills[skill] increased by 1, triggering further functions
Comments
The progress is preserved, but not the progressRequirement, when the skill is modified by a script function. The skill tooltip always shows the skill progression as a bar labelled with x/100.
Player level progress
Actions affected | On skill level-up |
Description | |
Implementation status | Implemented |
Analysis status | Verified |
On new game
totalIncreases = 0 # global counter of skill increases
attribCounter[8] = [0,0,0,0,0,0,0,0] # counter of attribute bonuses
On skill level-up
if skill in player.majorSkills:
totalIncreases += iLevelUpMajorMult
attribCounter[skill->basicAttribute] += iLevelUpMajorMultAttribute
elif skill in player.minorSkills:
totalIncreases += iLevelUpMinorMult
attribCounter[skill->basicAttribute] += iLevelUpMinorMultAttribute
elif skill in player.miscSkills:
attribCounter[skill->basicAttribute] += iLevelupMiscMultAttriubte # note game setting name has a typo
if totalIncreases >= iLevelUpTotal:
level up becomes available
totalIncreases -= iLevelUpTotal # note rollover mechanic
attribCounter[8] = [0,0,0,0,0,0,0,0]
Levelling up
Actions affected | On resting when a level up is available |
Description | |
Implementation status | Implemented |
Analysis status | Verified |
On level up, the player can select up to 3 attributes to improve. Less than 3 may be offered if there are less than 3 attributes that can be improved (attribute < 100). Attributes already >= 100 cannot be selected.
for each attribute:
count = attribCounter[attribute]
if count == 0:
bonus = 1
elif count <= 9:
bonus = {iLevelUp01Mult, .. iLevelUp09Mult} selected by count
else: # count >= 10
bonus = iLevelUp10Mult
bonus = min(bonus, 100 - player.attribute[selectedAttribute])
NPC Auto-calculate Stats
Actions affected | On creating an NPC flagged with auto-calculate |
Description | NPCs' auto-calculated stats. Affected by race, class, faction and rank. |
Implementation status | Implemented |
Analysis status | Verified |
Attributes
for each attribute:
base = race base attribute (+ 10 if a class primary attribute)
k = 0
for each skill with this governing attribute:
if skill is class major: k += 1
if skill is class minor: k += 0.5
if skill is miscellaneous: k += 0.2
final attribute = base + k * (level - 1)
round attribute to nearest, half to nearest even (standard IEEE 754 rounding mode)
Health
mult = 3
+ 2 if class specialization is combat
+ 1 if class specialization is stealth
+ 1 if endurance is a primary attribute
health = floor(0.5 * (strength + endurance) + mult * (level - 1))
Skills
for each skill:
if skill is class major: base = 30, k = 1
if skill is class minor: base = 15, k = 1
if skill is miscellaneous: base = 5, k = 0.1
if skill is in class specialization: base += 5, k += 0.5
if skill has race bonus: base += racebonus
final skill = base + k * (level - 1)
round skill to nearest, half to nearest even (standard IEEE 754 rounding mode)
Reputation
if not in a faction:
reputation = 0
else:
reputation = iAutoRepFacMod * rank + iAutoRepLevMod * (level - 1)
where the entry level rank in the faction means rank = 1
Comments
The correct rounding mode is critical for accurate skills, which affect important gameplay like spell auto-selection and training.
Note that there are level 0 NPCs in the game, so the (level - 1) term will become -1; use a signed representation of level in calculations.
NPC Auto-calculate Spells
Actions affected | On creating an NPC flagged with auto-calculate |
Description | |
Implementation status | Implemented |
Analysis status | Verified |
Common functions
function calcWeakestSchool :: (spell, actor) -> (effectiveSchool, skillTerm)
minChance = FLOAT_MAX
for each effect in spell:
x = effect.duration
if not effect.magicEffect.flags & UNCAPPED_DAMAGE: x = max(1, x)
x *= 0.1 * effect.magicEffect.baseMagickaCost
x *= 0.5 * (effect.magnitudeMin + effect.magnitudeMax)
x += effect.radius * 0.05 * effect.magicEffect.baseMagickaCost
if effect.rangeType & CAST_TARGET: x *= 1.5
x *= fEffectCostMult
s = 2 * actor.skill[effect.magicEffect.school.associatedSkillId]
if (s - x) < minChance:
minChance = s - x
effectiveSchool = effect.magicEffect.school
skillTerm = s
return effectiveSchool, skillTerm
function calcAutoCastChance :: (spell, actor, effectiveSchool) -> castChance
if spell.castingType != spell: return 100
if spell is flagged always succeeds: return 100
if effectiveSchool != none:
skillTerm = 2 * actor.skill[effectiveSchool.associatedSkillId]
else:
_, skillTerm = calcWeakestSchool(spell, actor)
castChance = skillTerm - spell.cost + 0.2 * actor.willpower + 0.1 * actor.luck
return castChance
NPC spells
baseActor.spells = vector()
baseMagicka = fNPCbaseMagickaMult * baseActor.intelligence
spellSchools = { Alteration, Conjuration, Destruction, Illusion, Mysticism, Restoration }
schoolCaps = {} # could be an array indexed by school enum
for each school in spellSchools:
schoolCaps[school] = { count : 0,
limit : iAutoSpell{school}Max,
reachedLimit : iAutoSpell{school}Max <= 0,
minCost : INT_MAX,
weakestSpell : none }
for each spell in the game: # note: iteration order is important, see comments
if spell.isMarkedDeleted: continue
if spell.castingType != spell: continue
if not spell.isAutoCalculate: continue
if baseMagicka < iAutoSpellTimesCanCast * spell.cost: continue
if spell is in baseActor.race.racialSpells: continue
failedAttrSkillCheck = false
for each effect in spell:
if (effect.baseEffect.flags & TARGET_SKILL) and baseActor.skills[effect.targetSkill] < iAutoSpellAttSkillMin:
failedAttrSkillCheck = true
break
if (effect.baseEffect.flags & TARGET_ATTR) and baseActor.attribute[effect.targetAttr] < iAutoSpellAttSkillMin:
failedAttrSkillCheck = true
break
if failedAttrSkillCheck: continue
school, _ = calcWeakestSchool(spell, actor)
cap = schoolCaps[school]
if cap.reachedLimit and spell.cost <= cap.minCost: continue
if calcBaseCastChance(baseActor, spell, school) < fAutoSpellChance: continue
baseActor.spells.add(spell)
if cap.reachedLimit:
baseActor.spells.remove(cap.weakestSpell)
cap.weakestSpell = baseActor.spells.findMinCostSpell() # note: not school specific
cap.minCost = cap.weakestSpell.cost
else:
cap.count += 1
if cap.count == cap.limit:
cap.reachedLimit = true
if spell.cost < cap.minCost:
cap.weakestSpell = spell
cap.minCost = spell.cost
Comments
Auto-calculated spells are selected at initial loading time. baseActor refers to the actor with attributes as loaded or auto-calculated, without any kind of spell effects (i.e. abilities) applied.
Due to poor design, iteration order is critical to the algorithm's behaviour. The list of all spells in the game must be in the same order as loaded from the files. NPC spells are held in a vector, which the findMinCostSpell function scans linearly; during the scan, if there is another spell that matches the lowest cost, it keeps the first one found.
Note that when a spell school is past its limit, the weakest spell is removed, and a new weakest spell is selected. This may not be a spell from the same school as the limit. While this is undesired behaviour, fixing it is likely to cause a major difference from vanilla spell selection, which will not have been play tested. It's not recommend to fix this part at the moment.
PC Starting Spells
Actions affected | On reviewing player stats, after race, class and sign are selected |
Description | Uses common functions from NPC auto-calc spells. baseActor is the player. |
Implementation status | Implemented |
Analysis status | Verified |
baseMagicka = fPCbaseMagickaMult * baseActor.intelligence
reachedLimit = false
weakestSpell = none
minCost = INT_MAX
baseActor.spells = vector()
for each spell in the game: # note: iteration order is important, see comments
if spell.isMarkedDeleted: continue
if spell.castingType != spell: continue
if not spell.isPCStartSpell: continue
if reachedLimit and spell.cost <= minCost: continue
if spell is in baseActor.spells: continue
if spell is in baseActor.race.racialSpells: continue
if baseMagicka < spell.cost: continue
if calcAutoCastChance(spell, baseActor, none) < fAutoPCSpellChance: continue
failedAttrSkillCheck = false
for each effect in spell:
if (effect.baseEffect.flags & TARGET_SKILL) and baseActor.skills[effect.targetSkill] < iAutoSpellAttSkillMin:
failedAttrSkillCheck = true
break
if (effect.baseEffect.flags & TARGET_ATTR) and baseActor.attribute[effect.targetAttr] < iAutoSpellAttSkillMin:
failedAttrSkillCheck = true
break
if failedAttrSkillCheck: continue
baseActor.spells.add(spell)
if reachedLimit:
baseActor.spells.remove(weakestSpell)
weakestSpell = baseActor.spells.findMinCostSpell()
minCost = weakestSpell.cost
else:
if spell.cost < minCost:
weakestSpell = spell
minCost = spell.cost
if baseActor.spells.size() == iAutoPCSpellMax:
reachedLimit = true
Comments
This is first executed once the stat review menu appear, and then executed every time the player modifies their character. The spell list must therefore be cleared before selecting new spells.
See NPC auto-calculated spells for additional comments.