Research:Stats and Levelling

From OpenMW Wiki
Jump to navigation Jump to search


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.