From fd3f23d068afc7c3ccd6c33454382214256f52b6 Mon Sep 17 00:00:00 2001 From: millanzarreta Date: Fri, 31 Jul 2026 09:50:06 +0200 Subject: [PATCH] [Warlock] Model Demonfire Infusion RNG and refine DoT tick hit-result handling --- engine/class_modules/warlock/sc_warlock.hpp | 5 +- .../warlock/sc_warlock_actions.cpp | 226 +++++++++++++----- .../class_modules/warlock/sc_warlock_init.cpp | 35 ++- 3 files changed, 197 insertions(+), 69 deletions(-) diff --git a/engine/class_modules/warlock/sc_warlock.hpp b/engine/class_modules/warlock/sc_warlock.hpp index 3c356177968..fdc4c44a7d4 100644 --- a/engine/class_modules/warlock/sc_warlock.hpp +++ b/engine/class_modules/warlock/sc_warlock.hpp @@ -1023,6 +1023,7 @@ struct warlock_t : public parse_player_effects_t { threshold_rng_t* agony_energize; threshold_rng_t* nightfall; + threshold_rng_t* demonfire_infusion; threshold_rng_t* seeds_of_their_demise; } progress_rng; @@ -1052,8 +1053,6 @@ struct warlock_t : public parse_player_effects_t simple_proc_t* immolate_crit_energize; // TODO: Need to check the type of rng simple_proc_t* demoniac_imp_implosion; simple_proc_t* carnivorous_stalkers; - simple_proc_t* demonfire_infusion_dot; // TODO: Need to check the type of rng - simple_proc_t* demonfire_infusion_inc; // TODO: Need to check the type of rng simple_proc_t* alythesss_ire_shift; simple_proc_t* wither_crit_energize; // TODO: Need to check the type of rng simple_proc_t* blackened_soul; @@ -1330,6 +1329,8 @@ namespace helpers virtual void execute() override; }; + unsigned incinerate_state_target_count( const action_state_t* state ); + void consume_succulent_soul( warlock_t* p, player_t* target ); void trigger_blackened_soul( warlock_t* p, bool malevolence, player_t* bs_target = nullptr ); diff --git a/engine/class_modules/warlock/sc_warlock_actions.cpp b/engine/class_modules/warlock/sc_warlock_actions.cpp index b118d8d65b0..bf3eda62019 100644 --- a/engine/class_modules/warlock/sc_warlock_actions.cpp +++ b/engine/class_modules/warlock/sc_warlock_actions.cpp @@ -480,7 +480,7 @@ using namespace helpers; p()->procs.ravenous_afflictions->occur(); } - if ( destruction() && p()->talents.reverse_entropy.ok() ) + if ( destruction() && p()->talents.reverse_entropy.ok() && result_is_hit( d->state->result ) ) { if ( p()->buffs.reverse_entropy->trigger() ) p()->procs.reverse_entropy->occur(); @@ -1105,37 +1105,43 @@ using namespace helpers; if ( affliction() ) { - if ( result_is_hit( d->state->result ) && p()->talents.nightfall.ok() && p()->progress_rng.nightfall->trigger( d->state ) ) - { - p()->procs.nightfall->occur(); - p()->buffs.nightfall->trigger(); - } - if ( p()->talents.siphon_life.ok() || ( p()->hero.seeds_of_their_demise.ok() && d->target->health_percentage() <= p()->hero.seeds_of_their_demise->effectN( 2 ).base_value() ) ) + if ( result_is_hit( d->state->result ) ) { - // Affliction Wither DoT ticks trigger procs when talented into Siphon Life - // Affliction Wither DoT ticks also trigger procs when attempting to start a collapse via Seeds of Their Demise - p()->trigger_aura_applied_callbacks( proc_data, p() ); + if ( p()->talents.nightfall.ok() && p()->progress_rng.nightfall->trigger( d->state ) ) + { + p()->procs.nightfall->occur(); + p()->buffs.nightfall->trigger(); + } + if ( p()->talents.siphon_life.ok() || ( p()->hero.seeds_of_their_demise.ok() && d->target->health_percentage() <= p()->hero.seeds_of_their_demise->effectN( 2 ).base_value() ) ) + { + // Affliction Wither DoT ticks trigger procs when talented into Siphon Life + // Affliction Wither DoT ticks also trigger procs when attempting to start a collapse via Seeds of Their Demise + p()->trigger_aura_applied_callbacks( proc_data, p() ); + } } } if ( destruction() ) { - if ( d->state->result == RESULT_CRIT && p()->flat_rng.wither_crit_energize->trigger() ) - p()->resource_gain( RESOURCE_SOUL_SHARD, 0.1, p()->gains.wither_crits ); + if ( result_is_hit( d->state->result ) ) + { + if ( d->state->result == RESULT_CRIT && p()->flat_rng.wither_crit_energize->trigger() ) + p()->resource_gain( RESOURCE_SOUL_SHARD, 0.1, p()->gains.wither_crits ); - p()->resource_gain( RESOURCE_SOUL_SHARD, 0.1, p()->gains.wither ); + p()->resource_gain( RESOURCE_SOUL_SHARD, 0.1, p()->gains.wither ); - if ( p()->talents.flashpoint.ok() && d->target->health_percentage() >= p()->talents.flashpoint->effectN( 2 ).base_value() ) - p()->buffs.flashpoint->trigger(); + if ( p()->talents.flashpoint.ok() && d->target->health_percentage() >= p()->talents.flashpoint->effectN( 2 ).base_value() ) + p()->buffs.flashpoint->trigger(); - if ( p()->talents.demonfire_infusion.ok() && p()->flat_rng.demonfire_infusion_dot->trigger() ) - { - p()->proc_actions.demonfire_infusion->execute_on_target( d->target ); - p()->procs.demonfire_infusion_dot->occur(); - } + if ( p()->talents.demonfire_infusion.ok() && p()->progress_rng.demonfire_infusion->trigger( d->state ) ) + { + p()->proc_actions.demonfire_infusion->execute_on_target( d->target ); + p()->procs.demonfire_infusion_dot->occur(); + } - // Destruction Wither DoT ticks trigger procs through some hidden trigger - p()->trigger_aura_applied_callbacks( proc_data, p() ); + // Destruction Wither DoT ticks trigger procs through some hidden trigger + p()->trigger_aura_applied_callbacks( proc_data, p() ); + } } // Seeds of their Demise collapse conditions must be checked periodically for every Wither tick @@ -1518,17 +1524,20 @@ using namespace helpers; void tick( dot_t* d ) override { - if ( p()->progress_rng.agony_energize->trigger( d->state ) ) - { - p()->resource_gain( RESOURCE_SOUL_SHARD, 1.0, p()->gains.agony ); + warlock_spell_t::tick( d ); - // Agony energize spell triggers procs - p()->trigger_aura_applied_callbacks( p()->proc_data_entries.agony_energize, p() ); - } + if ( result_is_hit( d->state->result ) ) + { + if ( p()->progress_rng.agony_energize->trigger( d->state ) ) + { + p()->resource_gain( RESOURCE_SOUL_SHARD, 1.0, p()->gains.agony ); - warlock_spell_t::tick( d ); + // Agony energize spell triggers procs + p()->trigger_aura_applied_callbacks( p()->proc_data_entries.agony_energize, p() ); + } - d->increment( 1 ); + d->increment( 1 ); + } } }; @@ -3886,8 +3895,39 @@ using namespace helpers; struct incinerate_t : public warlock_spell_t { + struct incinerate_state_t : public action_state_t + { + unsigned real_total_target_count; + + incinerate_state_t( action_t* action, player_t* target ) + : action_state_t( action, target ), + real_total_target_count( 1 ) + { } + + void initialize() override + { + action_state_t::initialize(); + real_total_target_count = 1; + } + + std::ostringstream& debug_str( std::ostringstream& s ) override + { + action_state_t::debug_str( s ); + s << " real_total_target_count=" << real_total_target_count; + return s; + } + + void copy_state( const action_state_t* s ) override + { + action_state_t::copy_state( s ); + real_total_target_count = debug_cast( s )->real_total_target_count; + } + }; + struct incinerate_fnb_t : public warlock_spell_t { + unsigned real_total_target_count = 1; + incinerate_fnb_t( warlock_t* p ) : warlock_spell_t( "Incinerate (Fire and Brimstone)", p, p->warlock_base.incinerate ) { @@ -3912,6 +3952,15 @@ using namespace helpers; double cost() const override { return 0.0; } + action_state_t* new_state() override + { return new incinerate_state_t( this, target ); } + + void snapshot_state( action_state_t* s, result_amount_type rt ) override + { + warlock_spell_t::snapshot_state( s, rt ); + debug_cast( s )->real_total_target_count = real_total_target_count; + } + size_t available_targets( std::vector& tl ) const override { warlock_spell_t::available_targets( tl ); @@ -3931,13 +3980,23 @@ using namespace helpers; { warlock_spell_t::impact( s ); - if ( p()->bugs && p()->talents.diabolic_embers.ok() && s->result == RESULT_CRIT ) - p()->resource_gain( RESOURCE_SOUL_SHARD, 0.1, p()->gains.incinerate_crits ); + if ( result_is_hit( s->result ) ) + { + if ( p()->bugs && p()->talents.diabolic_embers.ok() && s->result == RESULT_CRIT ) + p()->resource_gain( RESOURCE_SOUL_SHARD, 0.1, p()->gains.incinerate_crits ); + + if ( p()->talents.demonfire_infusion.ok() && p()->progress_rng.demonfire_infusion->trigger( s ) ) + { + p()->proc_actions.demonfire_infusion->execute_on_target( s->target ); + p()->procs.demonfire_infusion_inc->occur(); + } + } } }; double energize_mult; incinerate_fnb_t* fnb_action; + unsigned real_total_target_count = 1; incinerate_t( warlock_t* p, util::string_view options_str ) : warlock_spell_t( "Incinerate", p, p->warlock_base.incinerate, options_str ), @@ -3959,6 +4018,15 @@ using namespace helpers; add_child( fnb_action ); } + action_state_t* new_state() override + { return new incinerate_state_t( this, target ); } + + void snapshot_state( action_state_t* s, result_amount_type rt ) override + { + warlock_spell_t::snapshot_state( s, rt ); + debug_cast( s )->real_total_target_count = real_total_target_count; + } + // Custom init() to combine Havoc+FnB coefficients instead of using the generic warlock_spell_t::init() Havoc multiplier void init() override { @@ -3985,16 +4053,23 @@ using namespace helpers; { player_t* cast_target = target; - warlock_spell_t::execute(); + real_total_target_count = 1; + if ( use_havoc() ) + real_total_target_count = std::min( 2u, as( target_list().size() ) ); if ( p()->talents.fire_and_brimstone.ok() ) - fnb_action->execute_on_target( cast_target ); - - if ( p()->talents.demonfire_infusion.ok() && p()->flat_rng.demonfire_infusion_inc->trigger() ) { - p()->proc_actions.demonfire_infusion->execute_on_target( cast_target ); - p()->procs.demonfire_infusion_inc->occur(); + // FnB excludes the primary and Havoc targets, so both lists together contain every unique impact. + fnb_action->set_target( cast_target ); + real_total_target_count += as( fnb_action->target_list().size() ); + fnb_action->real_total_target_count = real_total_target_count; } + assert( real_total_target_count > 0 ); + + warlock_spell_t::execute(); + + if ( p()->talents.fire_and_brimstone.ok() ) + fnb_action->execute(); // Incinerate energize spell triggers procs p()->trigger_aura_applied_callbacks( p()->proc_data_entries.incinerate_energize, p() ); @@ -4014,13 +4089,22 @@ using namespace helpers; { warlock_spell_t::impact( s ); - // TOCHECK: 2025-08-27 Incinerate Havoc crit impacts don't give extra shards (bug?), and only 1 extra shard with Diabolic Embers - if ( s->result == RESULT_CRIT ) + if ( result_is_hit( s->result ) ) { - if ( !p()->bugs || s->chain_target == 0 ) - p()->resource_gain( RESOURCE_SOUL_SHARD, 0.1 * energize_mult, p()->gains.incinerate_crits ); - else if ( p()->talents.diabolic_embers.ok() ) - p()->resource_gain( RESOURCE_SOUL_SHARD, 0.1, p()->gains.incinerate_crits ); + // TOCHECK: 2025-08-27 Incinerate Havoc crit impacts don't give extra shards (bug?), and only 1 extra shard with Diabolic Embers + if ( s->result == RESULT_CRIT ) + { + if ( !p()->bugs || s->chain_target == 0 ) + p()->resource_gain( RESOURCE_SOUL_SHARD, 0.1 * energize_mult, p()->gains.incinerate_crits ); + else if ( p()->talents.diabolic_embers.ok() ) + p()->resource_gain( RESOURCE_SOUL_SHARD, 0.1, p()->gains.incinerate_crits ); + } + + if ( p()->talents.demonfire_infusion.ok() && p()->progress_rng.demonfire_infusion->trigger( s ) ) + { + p()->proc_actions.demonfire_infusion->execute_on_target( s->target ); + p()->procs.demonfire_infusion_inc->occur(); + } } } }; @@ -4041,22 +4125,25 @@ using namespace helpers; { warlock_spell_t::tick( d ); - if ( d->state->result == RESULT_CRIT && p()->flat_rng.immolate_crit_energize->trigger() ) - p()->resource_gain( RESOURCE_SOUL_SHARD, 0.1, p()->gains.immolate_crits ); + if ( result_is_hit( d->state->result ) ) + { + if ( d->state->result == RESULT_CRIT && p()->flat_rng.immolate_crit_energize->trigger() ) + p()->resource_gain( RESOURCE_SOUL_SHARD, 0.1, p()->gains.immolate_crits ); - p()->resource_gain( RESOURCE_SOUL_SHARD, 0.1, p()->gains.immolate ); + p()->resource_gain( RESOURCE_SOUL_SHARD, 0.1, p()->gains.immolate ); - if ( p()->talents.flashpoint.ok() && d->target->health_percentage() >= p()->talents.flashpoint->effectN( 2 ).base_value() ) - p()->buffs.flashpoint->trigger(); + if ( p()->talents.flashpoint.ok() && d->target->health_percentage() >= p()->talents.flashpoint->effectN( 2 ).base_value() ) + p()->buffs.flashpoint->trigger(); - if ( p()->talents.demonfire_infusion.ok() && p()->flat_rng.demonfire_infusion_dot->trigger() ) - { - p()->proc_actions.demonfire_infusion->execute_on_target( d->target ); - p()->procs.demonfire_infusion_dot->occur(); - } + if ( p()->talents.demonfire_infusion.ok() && p()->progress_rng.demonfire_infusion->trigger( d->state ) ) + { + p()->proc_actions.demonfire_infusion->execute_on_target( d->target ); + p()->procs.demonfire_infusion_dot->occur(); + } - // Immolate DoT ticks trigger procs through some hidden trigger - p()->trigger_aura_applied_callbacks( proc_data, p() ); + // Immolate DoT ticks trigger procs through some hidden trigger + p()->trigger_aura_applied_callbacks( proc_data, p() ); + } } }; @@ -5262,17 +5349,8 @@ using namespace helpers; void execute() override { - player_t* ib_target = target; - warlock_spell_t::execute(); - // 2026-02-18 Infernal Bolt can proc Demonfire Infusion - if ( p()->talents.demonfire_infusion.ok() && p()->flat_rng.demonfire_infusion_inc->trigger() ) - { - p()->proc_actions.demonfire_infusion->execute_on_target( ib_target ); - p()->procs.demonfire_infusion_inc->occur(); - } - // Infernal Bolt energize spell effect triggers procs p()->trigger_aura_applied_callbacks( proc_data, p() ); @@ -5284,6 +5362,18 @@ using namespace helpers; if ( time_to_execute == 0_ms ) p()->buffs.chaotic_inferno->decrement(); } + + void impact( action_state_t* s ) override + { + warlock_spell_t::impact( s ); + + // 2026-07-29 Infernal Bolt hits can proc Demonfire Infusion + if ( result_is_hit( s->result ) && p()->talents.demonfire_infusion.ok() && p()->progress_rng.demonfire_infusion->trigger( s ) ) + { + p()->proc_actions.demonfire_infusion->execute_on_target( s->target ); + p()->procs.demonfire_infusion_inc->occur(); + } + } }; struct ruination_t : public warlock_spell_t @@ -5532,6 +5622,12 @@ using namespace helpers; // Diabolist Actions End // Helper Functions Begin + unsigned helpers::incinerate_state_target_count( const action_state_t* state ) + { + assert( state ); + return debug_cast( state )->real_total_target_count; + } + void helpers::consume_succulent_soul( warlock_t* p, player_t* target ) { if ( !p->buffs.succulent_soul->check() ) diff --git a/engine/class_modules/warlock/sc_warlock_init.cpp b/engine/class_modules/warlock/sc_warlock_init.cpp index fd3f301a24a..a5592600515 100644 --- a/engine/class_modules/warlock/sc_warlock_init.cpp +++ b/engine/class_modules/warlock/sc_warlock_init.cpp @@ -1413,8 +1413,39 @@ namespace warlock if ( talents.demonfire_infusion.ok() ) { - flat_rng.demonfire_infusion_dot = get_simple_proc_rng( "demonfire_infusion_dot", talents.demonfire_infusion->effectN( 1 ).percent() ); - flat_rng.demonfire_infusion_inc = get_simple_proc_rng( "demonfire_infusion_incinerate", talents.demonfire_infusion->effectN( 2 ).percent() ); + // Uniform sources use twice the spell-data chance as their maximum so their mean increment matches that chance + const double inc_max_demonfire_infusion_dot = talents.demonfire_infusion->effectN( 1 ).percent() * 2.0; + const double inc_max_demonfire_infusion_inc = talents.demonfire_infusion->effectN( 2 ).percent() * 2.0; + const double inc_fixed_demonfire_infusion_ib = talents.demonfire_infusion->effectN( 2 ).percent(); + const bool fnb_talent = talents.fire_and_brimstone.ok(); + + progress_rng.demonfire_infusion = get_threshold_rng( "demonfire_infusion", inc_max_demonfire_infusion_dot, + [ this, inc_max_demonfire_infusion_inc, inc_fixed_demonfire_infusion_ib, fnb_talent ]( double increment_max, action_state_t* s ) { + assert( s ); + if ( s->action->id == warlock_base.incinerate->id() ) + { + // Incinerate uses a source-specific uniform increment maximum instead of the default increment_max + increment_max = inc_max_demonfire_infusion_inc; + if ( fnb_talent ) + { + // With FnB, Incinerate's expected total accumulator contribution scales with the cube root of the cast's unique impacts, including Havoc + const unsigned target_count = helpers::incinerate_state_target_count( s ); + assert( target_count > 0 ); + increment_max *= std::pow( target_count, -2.0 / 3.0 ); + } + } + else if ( s->action->id == hero.infernal_bolt->id() ) + { + // Infernal Bolt impacts, including Havoc, add a fixed value to the accumulator + return inc_fixed_demonfire_infusion_ib; + } + else + { + // Immolate and Wither use the default increment_max initialized from inc_max_demonfire_infusion_dot + assert( s->action->id == warlock_base.immolate_dot->id() || s->action->id == hero.wither_dot->id() ); + } + return rng().range( 0.0, increment_max ); + }, true, true ); } if ( talents.alythesss_ire.ok() )