Improve handling of trigger chance. (#7244)

* FIX: Trigger chance not being handled propery. Improve sim runtime.

* FEAT: documentation and minor speedup
This commit is contained in:
Paliak
2024-07-21 22:16:05 +02:00
committed by GitHub
parent e9dd799221
commit 0b8dd5e38b

View File

@@ -85,16 +85,13 @@ end
-- Calculate the impact other skills and source rate to trigger cooldown alignment have on the trigger rate
-- for more details regarding the implementation see comments of #4599 and #5428
function calcMultiSpellRotationImpact(env, skillRotation, sourceRate, triggerCD, actor)
local SIM_TIME = 100.0
local TIME_STEP = 0.0001
local index = 1
local time = 0
local tick = 0
local currTick = 0
function calcMultiSpellRotationImpact(env, skillRotation, sourceRate, triggerCD, chance, actor)
local rotationIndex = 1
local next_trigger = 0
local trigger_increment = 1 / sourceRate
local wasted = 0
local triggerIncrement = 1 / sourceRate
local SIM_TIME = triggerIncrement * 1000 -- Simulate 1000 attacks
local chance = chance or 100
local skillCount = #skillRotation
local actor = actor or env.player
for _, skill in ipairs(skillRotation) do
@@ -103,51 +100,31 @@ function calcMultiSpellRotationImpact(env, skillRotation, sourceRate, triggerCD,
skill.count = 0
end
while time < SIM_TIME do
local currIndex = index
if time >= next_trigger then
while skillRotation[index].next_trig > time do
index = (index % #skillRotation) + 1
if index == currIndex then
wasted = wasted + 1
-- Triggers are free from the server tick so cooldown starts at current time
next_trigger = time + trigger_increment
break
end
while next_trigger < SIM_TIME do
local currentIndex = rotationIndex
repeat
if skillRotation[currentIndex].next_trig <= next_trigger then -- Skill at current index off cooldown, Trigger it.
skillRotation[currentIndex].count = skillRotation[currentIndex].count + 1
-- Cooldown starts at the beginning of current tick and ends at the next tick after cooldown expiration
skillRotation[currentIndex].next_trig = ceil_b(floor_b(next_trigger, 0.033) + skillRotation[currentIndex].cd, 0.033)
break
end
if skillRotation[index].next_trig <= time then
skillRotation[index].count = skillRotation[index].count + 1
-- Cooldown starts at the beginning of current tick
skillRotation[index].next_trig = currTick + skillRotation[index].cd
local tempTick = tick
while skillRotation[index].next_trig > tempTick do
tempTick = tempTick + (1/data.misc.ServerTickRate)
end
-- Cooldown ends at the start of the next tick. Price is right rules.
skillRotation[index].next_trig = tempTick
index = (index % #skillRotation) + 1
next_trigger = time + trigger_increment
end
end
-- Increment time by smallest reasonable amount to attempt to hit every trigger event and every server tick. Frees attacks from the server tick.
time = time + TIME_STEP
-- Keep track of the server tick as the trigger cooldown is still bound by it
if tick < time then
currTick = tick
tick = tick + (1/data.misc.ServerTickRate)
end
currentIndex = (currentIndex % skillCount) + 1 -- Current skill on cooldown, try the next one.
until(currentIndex == rotationIndex) -- All skills checked, trigger wasted
rotationIndex = (rotationIndex % skillCount) + 1 -- Move on to the next skill in rotation
next_trigger = next_trigger + triggerIncrement
end
local mainRate = 0
local trigRateTable = { simTime = SIM_TIME, rates = {}, }
local mainRate = 0
for _, sd in ipairs(skillRotation) do
-- Account for trigger chance. Adds the expected value of a geometric distribution where p = chance multiplied by triggerIncrement
-- This allows for O(1) estimation of trigger chance impact on trigger rate as number of triggers approaches infinity
-- Credit to Logik and Quickstick. More info in prs linked here: https://github.com/PathOfBuildingCommunity/PathOfBuilding/pull/7244 and on discord.
t_insert(trigRateTable.rates, { name = sd.uuid, rate = 1 / (SIM_TIME / sd.count + (triggerIncrement / chance * 100) - triggerIncrement) })
if cacheSkillUUID(actor.mainSkill, env) == sd.uuid then
mainRate = sd.count / SIM_TIME
mainRate = trigRateTable.rates[#trigRateTable.rates].rate
end
t_insert(trigRateTable.rates, { name = sd.uuid, rate = sd.count / SIM_TIME })
end
return mainRate, trigRateTable
@@ -497,25 +474,6 @@ local function defaultTriggerHandler(env, config)
end
end
--Accuracy and crit chance
if source and (source.skillTypes[SkillType.Melee] or source.skillTypes[SkillType.Attack]) and GlobalCache.cachedData[env.mode][uuid] and not config.triggerOnUse then
local sourceHitChance = GlobalCache.cachedData[env.mode][uuid].HitChance
trigRate = trigRate * (sourceHitChance or 0) / 100
if breakdown then
t_insert(breakdown.EffectiveSourceRate, s_format("x %.0f%% ^8(%s hit chance)", sourceHitChance, source.activeEffect.grantedEffect.name))
end
if actor.mainSkill.skillData.triggerOnCrit then
local onCritChance = actor.mainSkill.skillData.chanceToTriggerOnCrit or (GlobalCache.cachedData[env.mode][uuid] and GlobalCache.cachedData[env.mode][uuid].Env.player.mainSkill.skillData.chanceToTriggerOnCrit)
config.triggerChance = config.triggerChance or actor.mainSkill.skillData.chanceToTriggerOnCrit or onCritChance
local sourceCritChance = GlobalCache.cachedData[env.mode][uuid].CritChance
trigRate = trigRate * (sourceCritChance or 0) / 100
if breakdown then
t_insert(breakdown.EffectiveSourceRate, s_format("x %.2f%% ^8(%s effective crit chance)", sourceCritChance, source.activeEffect.grantedEffect.name))
end
end
end
--Special handling for Kitava's Thirst
-- Repeated hits do not consume mana and do not trigger Kitava's thirst
if actor.mainSkill.skillData.triggeredByManaSpent then
@@ -560,19 +518,6 @@ local function defaultTriggerHandler(env, config)
end
end
--Trigger chance
if config.triggerChance and config.triggerChance ~= 100 and trigRate then
trigRate = trigRate * config.triggerChance / 100
if breakdown and breakdown.EffectiveSourceRate then
t_insert(breakdown.EffectiveSourceRate, s_format("x %.2f%% ^8(chance to trigger)", config.triggerChance))
elseif breakdown then
breakdown.EffectiveSourceRate = {
s_format("%.2f ^8(adjusted trigger rate)", trigRate),
s_format("x %.2f%% ^8(chance to trigger)", config.triggerChance),
}
end
end
local icdr = calcLib.mod(actor.mainSkill.skillModList, actor.mainSkill.skillCfg, "CooldownRecovery") or 1
local addedCooldown = actor.mainSkill.skillModList:Sum("BASE", actor.mainSkill.skillCfg, "CooldownRecovery")
addedCooldown = addedCooldown ~= 0 and addedCooldown or nil
@@ -743,6 +688,40 @@ local function defaultTriggerHandler(env, config)
local skillName = (source and source.activeEffect.grantedEffect.name) or (actor.mainSkill.triggeredBy and actor.mainSkill.triggeredBy.grantedEffect.name) or actor.mainSkill.activeEffect.grantedEffect.name
if output.EffectiveSourceRate ~= 0 then
local triggerChance = 100
local triggerChanceBreakdown = {}
--Accuracy and crit chance
if source and (source.skillTypes[SkillType.Melee] or source.skillTypes[SkillType.Attack]) and GlobalCache.cachedData[env.mode][uuid] and not config.triggerOnUse then
local sourceHitChance = GlobalCache.cachedData[env.mode][uuid].HitChance
if sourceHitChance ~= 100 then
triggerChance = triggerChance * (sourceHitChance or 0) / 100
if breakdown then
t_insert(triggerChanceBreakdown, s_format("x %d%% ^8(%s hit chance)", sourceHitChance, source.activeEffect.grantedEffect.name))
end
end
if actor.mainSkill.skillData.triggerOnCrit then
local onCritChance = actor.mainSkill.skillData.chanceToTriggerOnCrit or (GlobalCache.cachedData[env.mode][uuid] and GlobalCache.cachedData[env.mode][uuid].Env.player.mainSkill.skillData.chanceToTriggerOnCrit)
config.triggerChance = config.triggerChance or actor.mainSkill.skillData.chanceToTriggerOnCrit or onCritChance
local sourceCritChance = GlobalCache.cachedData[env.mode][uuid].CritChance
if sourceCritChance ~= 100 then
triggerChance = triggerChance * (sourceCritChance or 0) / 100
if breakdown then
t_insert(triggerChanceBreakdown, s_format("x %d%% ^8(%s effective crit chance)", sourceCritChance, source.activeEffect.grantedEffect.name))
end
end
end
end
--Trigger chance
if config.triggerChance and config.triggerChance ~= 100 then
triggerChance = triggerChance * config.triggerChance / 100
if breakdown then
t_insert(triggerChanceBreakdown, s_format("x %.2f%% ^8(chance to trigger)", config.triggerChance))
end
end
-- If the current triggered skill ignores tick rate and is the only triggered skill by this trigger use charge based calcs
if actor.mainSkill.skillData.ignoresTickRate and ( not config.triggeredSkillCond or (triggeredSkills and #triggeredSkills == 1 and triggeredSkills[1] == packageSkillDataForSimulation(actor.mainSkill, env)) ) then
local overlaps = config.stagesAreOverlaps and env.player.mainSkill.skillPart == config.stagesAreOverlaps and env.player.mainSkill.activeEffect.srcInstance.skillStageCount or config.overlaps
@@ -759,9 +738,9 @@ local function defaultTriggerHandler(env, config)
end
end
elseif actor.mainSkill.skillFlags.globalTrigger and not config.triggeredSkillCond then -- Trigger does not use source rate breakpoints for one reason or another
output.SkillTriggerRate = output.EffectiveSourceRate
output.SkillTriggerRate = output.EffectiveSourceRate * triggerChance
else -- Triggers like Cast on Crit go through simulation to calculate the trigger rate of each skill in the trigger group
output.SkillTriggerRate, simBreakdown = calcMultiSpellRotationImpact(env, config.triggeredSkillCond and triggeredSkills or {packageSkillDataForSimulation(actor.mainSkill, env)}, output.EffectiveSourceRate, (not actor.mainSkill.skillData.triggeredByBrand and ( triggerCD or triggeredCD ) or 0), actor)
output.SkillTriggerRate, simBreakdown = calcMultiSpellRotationImpact(env, config.triggeredSkillCond and triggeredSkills or {packageSkillDataForSimulation(actor.mainSkill, env)}, output.EffectiveSourceRate, (not actor.mainSkill.skillData.triggeredByBrand and ( triggerCD or triggeredCD ) or 0), triggerChance, actor)
local triggerBotsEffective = actor.modDB:Flag(nil, "HaveTriggerBots") and actor.mainSkill.skillTypes[SkillType.Spell]
if triggerBotsEffective then
output.SkillTriggerRate = 2 * output.SkillTriggerRate
@@ -770,12 +749,20 @@ local function defaultTriggerHandler(env, config)
-- stagesAreOverlaps is the skill part which makes the stages behave as overlaps
local hits_per_cast = config.stagesAreOverlaps and env.player.mainSkill.skillPart == config.stagesAreOverlaps and env.player.mainSkill.activeEffect.srcInstance.skillStageCount or 1
output.SkillTriggerRate = hits_per_cast * output.SkillTriggerRate
if breakdown and (#triggeredSkills > 1 or triggerBotsEffective or hits_per_cast > 1) then
if breakdown then
breakdown.SkillTriggerRate = {
s_format("%.2f ^8(%s)", output.EffectiveSourceRate, (actor.mainSkill.skillData.triggeredByBrand and s_format("%s activations per second", source.activeEffect.grantedEffect.name)) or (not trigRate and s_format("%s triggers per second", skillName)) or "Effective source rate"),
s_format("/ %.2f ^8(Estimated impact of skill rotation and cooldown alignment)", m_max(output.EffectiveSourceRate / output.SkillTriggerRate, 1)),
s_format("/ %.2f ^8(Estimated impact of skill rotation, cooldown alignment and trigger chance)", m_max(output.EffectiveSourceRate / output.SkillTriggerRate, 1)),
s_format("= %.2f ^8per second", output.SkillTriggerRate),
}
if triggerChance ~= 100 then
t_insert(breakdown.SkillTriggerRate, 1, "")
t_insert(breakdown.SkillTriggerRate, 1, s_format("= %d%%", triggerChance))
for _, line in ipairs(triggerChanceBreakdown) do
t_insert(breakdown.SkillTriggerRate, 1, line)
end
t_insert(breakdown.SkillTriggerRate, 1, "100% ^8(Base chance)")
end
if triggerBotsEffective then
t_insert(breakdown.SkillTriggerRate, 3, "x 2 ^8(Trigger bots effectively cause the skill to trigger twice)")
end
@@ -809,6 +796,7 @@ local function defaultTriggerHandler(env, config)
}
t_insert(breakdown.SimData.rowList, row)
end
t_insert(breakdown.SimData, s_format("Simulation duration: %.2f ^8(In game source skill usage duration in seconds)", simBreakdown.simTime, simBreakdown.simTime))
end
end
else