<commit_before>import SPELLS from 'common/SPELLS/index';
import ITEMS from 'common/ITEMS/index';
import { calculateSecondaryStatDefault } from 'common/stats';
import { formatMilliseconds } from 'common/format';
import SPECS from 'game/SPECS';
import RACES from 'game/RACES';
import Analyzer, { SELECTED_PLAYER } from 'parser/core/Analyzer';
import EventEmitter from 'parser/core/modules/EventEmitter';
import Events, { EventType } from 'parser/core/Events';
import STAT from 'parser/shared/modules/features/STAT';

const ARMOR_INT_BONUS = .05;

const debug = false;

// TODO: stat constants somewhere else? they're largely copied from combatant
class StatTracker extends Analyzer {
  static dependencies = {
    eventEmitter: EventEmitter,
  };

  // These are multipliers to the stats applied *on pull* that are not
  // included in the stats reported by WCL. These are *baked in* and do
  // not multiply temporary buffs.
  //
  // In general, it looks like armor is the only one that isn't applied
  // by WCL.
  static SPEC_MULTIPLIERS = {
    [SPECS.BREWMASTER_MONK.id]: { armor: 1.25 },
  };

  static DEFAULT_BUFFS = {
    // region Potions
    [SPELLS.POTION_OF_SPECTRAL_AGILITY.id]: { agility: 190 },
    [SPELLS.POTION_OF_SPECTRAL_INTELLECT.id]: { intellect: 190 },
    [SPELLS.POTION_OF_SPECTRAL_STRENGTH.id]: { strength: 190 },
    [SPELLS.POTION_OF_SPECTRAL_STAMINA.id]: { stamina: 285 },
    [SPELLS.POTION_OF_HARDENED_SHADOWS.id]: { armor: 275 },
    // endregion

    // region Runes
    [SPELLS.VEILED_AUGMENT_RUNE.id]: { strength: 18, agility: 18, intellect: 18 },
    // endregion

    //region Flasks
    [SPELLS.FLASK_OF_THE_CURRENTS.id]: { agility: 25 },
    [SPELLS.FLASK_OF_ENDLESS_FATHOMS.id]: { intellect: 25 },
    [SPELLS.FLASK_OF_THE_UNDERTOW.id]: { strength: 25 },
    [SPELLS.FLASK_OF_THE_VAST_HORIZON.id]: { stamina: 38 },
    [SPELLS.GREATER_FLASK_OF_THE_CURRENTS.id]: { agility: 38 },
    [SPELLS.GREATER_FLASK_OF_ENDLESS_FATHOMS.id]: { intellect: 38 },
    [SPELLS.GREATER_FLASK_OF_THE_UNDERTOW.id]: { strength: 38 },
    [SPELLS.GREATER_FLASK_OF_THE_VAST_HORIZON.id]: { stamina: 57 },
    [SPELLS.SPECTRAL_FLASK_OF_POWER.id]: { strength: 70, agility: 70, intellect: 70 },
    [SPELLS.SPECTRAL_FLASK_OF_STAMINA.id]: { stamina: 105 },
    // endregion

    //region Food
    [SPELLS.BOUNTIFUL_CAPTAIN_FEAST_INT.id]: { intellect: 15 },
    [SPELLS.BOUNTIFUL_CAPTAIN_FEAST_STR.id]: { strength: 15 },
    [SPELLS.BOUNTIFUL_CAPTAIN_FEAST_AGI.id]: { agility: 15 },
    [SPELLS.FAMINE_EVALUATOR_AND_SNACK_TABLE_FEAST_INT.id]: { intellect: 16 },
    [SPELLS.FAMINE_EVALUATOR_AND_SNACK_TABLE_FEAST_STR.id]: { strength: 16 },
    [SPELLS.FAMINE_EVALUATOR_AND_SNACK_TABLE_FEAST_AGI.id]: { agility: 16 },
    [SPELLS.ABYSSAL_FRIED_RISSOLE.id]: { mastery: 14 },
    [SPELLS.BIL_TONG.id]: { versatility: 14 },
    [SPELLS.MECH_DOWELS_BIG_MECH.id]: { crit: 14 },
    [SPELLS.BAKED_PORT_TATO.id]: { haste: 14 },
    [SPELLS.FRAGRANT_KAKAVIA.id]: { stamina: 29 },
    [SPELLS.BORALUS_BLOOD_SAUSAGE_AGI.id]: { agility: 13 },
    [SPELLS.BORALUS_BLOOD_SAUSAGE_INT.id]: { intellect: 13 },
    [SPELLS.BORALUS_BLOOD_SAUSAGE_STR.id]: { strength: 13 },
    [SPELLS.WELL_FED_SEASONED_STEAK_AND_POTATOES.id]: { stamina: 22 },

    [SPELLS.PICKLED_MEAT_SMOOTHIE.id]: { stamina: 14 },
    [SPELLS.SWEET_SILVERGILL_SAUSAGES.id]: { versatility: 18 },
    [SPELLS.BUTTERSCOTCH_MARINATED_RIBS.id]: { crit: 18 },
    [SPELLS.MEATY_APPLE_DUMPLINGS.id]: { mastery: 18 },
    [SPELLS.CINNAMON_BONEFISH_STEW.id]: { haste: 18 },
    [SPELLS.BANANA_BEEF_PUDDING.id]: { stamina: 22 },
    [SPELLS.STEAK_A_LA_MODE.id]: { versatility: 30 },
    [SPELLS.TENEBROUS_CROWN_ROAST_ASPIC.id]: { haste: 30 },
    [SPELLS.SPINEFIN_SOUFFLE_AND_FRIES.id]: { crit: 30 },
    [SPELLS.IRIDESCENT_RAVIOLI_WITH_APPLE_SAUCE.id]: { mastery: 30 },
    [SPELLS.SURPRISINGLY_PALATABLE_FEAST_INT.id]: { intellect: 18 },
    [SPELLS.SURPRISINGLY_PALATABLE_FEAST_STR.id]: { strength: 18 },
    [SPELLS.SURPRISINGLY_PALATABLE_FEAST_AGI.id]: { agility: 18 },
    [SPELLS.FEAST_OF_GLUTTONOUS_HEDONISM_INT.id]: { intellect: 20 },
    [SPELLS.FEAST_OF_GLUTTONOUS_HEDONISM_STR.id]: { strength: 20 },
    [SPELLS.FEAST_OF_GLUTTONOUS_HEDONISM_AGI.id]: { agility: 20 },
    //endregion

    // region Misc
    [SPELLS.JACINS_RUSE.id]: { mastery: 48 },
    [SPELLS.MARK_OF_THE_CLAW.id]: { crit: 45, haste: 45 },
    // endregion

    // region Death Knight
    // endregion

    // region Druid
    [SPELLS.ASTRAL_HARMONY.id]: { mastery: 181 },
    // endregion

    // region Mage

    // endregion

    /****************************************\
     *               SHADOWLANDS:             *
     \****************************************/

     //Trinkets
     [SPELLS.INSCRUTABLE_QUANTUM_DEVICE_CRIT.id]: {
      itemId: ITEMS.INSCRUTABLE_QUANTUM_DEVICE.id,
      crit: (_, item) => calculateSecondaryStatDefault(184, 568, item.itemLevel),
    },
    [SPELLS.INSCRUTABLE_QUANTUM_DEVICE_HASTE.id]: {
      itemId: ITEMS.INSCRUTABLE_QUANTUM_DEVICE.id,
      haste: (_, item) => calculateSecondaryStatDefault(184, 568, item.itemLevel),
    },
    [SPELLS.INSCRUTABLE_QUANTUM_DEVICE_MASTERY.id]: {
      itemId: ITEMS.INSCRUTABLE_QUANTUM_DEVICE.id,
      mastery: (_, item) => calculateSecondaryStatDefault(184, 568, item.itemLevel),
    },
    [SPELLS.INSCRUTABLE_QUANTUM_DEVICE_VERS.id]: {
      itemId: ITEMS.INSCRUTABLE_QUANTUM_DEVICE.id,
      versatility: (_, item) => calculateSecondaryStatDefault(184, 568, item.itemLevel),
    },

    //endregion
    //endregion

    // region Racials
    // <NAME>
    [SPELLS.RICTUS_OF_THE_LAUGHING_SKULL.id]: { crit: 125 },
    [SPELLS.ZEAL_OF_THE_BURNING_BLADE.id]: { haste: 125 },
    [SPELLS.FEROCITY_OF_THE_FROSTWOLF.id]: { mastery: 125 },
    [SPELLS.MIGHT_OF_THE_BLACKROCK.id]: { versatility: 125 },
    // endregion
  };

  statBuffs = {};

  _pullStats = {};
  _currentStats = {};

  statMultiplier = {
    strength: 1,
    agility: 1,
    intellect: 1,
    stamina: 1,
    crit: 1,
    haste: 1, // should usually be done through the haste module
    mastery: 1,
    versatility: 1,
    avoidance: 1,
    leech: 1,
    speed: 1,
    armor: 1,
  };
  statMultiplierBuffs = {
    [SPELLS.ARCANE_INTELLECT.id]: { intellect: 1.05 },
    [SPELLS.BATTLE_SHOUT.id]: { strength: 1.05, agility: 1.05 },
  };

  //Values taken from https://github.com/simulationcraft/simc/blob/shadowlands/engine/dbc/generated/sc_scale_data.inc
  statBaselineRatingPerPercent = {
    /** Secondaries */
    [STAT.CRITICAL_STRIKE]: 35,
    [STAT.HASTE]: 33,
    [STAT.MASTERY]: 35,
    [STAT.VERSATILITY]: 40,
    /** Tertiaries */
    [STAT.AVOIDANCE]: 14,
    [STAT.LEECH]: 21,
    [STAT.SPEED]: 10,
  };

  /** Secondary stat scaling thresholds
   * Taken from https://raw.githubusercontent.com/simulationcraft/simc/shadowlands/engine/dbc/generated/item_scaling.inc
   * Search for 21024 in the first column for secondary stat scaling
   */
  secondaryStatPenaltyThresholds = [
    /** Secondary stat scaling thresholds */
    { base: 0, scaled: 0, penaltyAboveThis: 0 },
    { base: 0.3, scaled: 0.3, penaltyAboveThis: 0.1 },
    { base: 0.4, scaled: 0.39, penaltyAboveThis: 0.2 },
    { base: 0.5, scaled: 0.47, penaltyAboveThis: 0.3 },
    { base: 0.6, scaled: 0.54, penaltyAboveThis: 0.4 },
    { base: 0.8, scaled: 0.66, penaltyAboveThis: 0.5 },
    { base: 1, scaled: 0.76, penaltyAboveThis: 0.5 },
    { base: 2, scaled: 1.26, penaltyAboveThis: 1 },
  ];

  /** Tertiary stat scaling thresholds
   * Taken from https://raw.githubusercontent.com/simulationcraft/simc/shadowlands/engine/dbc/generated/item_scaling.inc
   * Search for 21025 in the first column for tertiary stat scaling
   */
  tertiaryStatPenaltyThresholds = [
    /** Tertiary stat scaling thresholds */
    { base: 0, scaled: 0, penaltyAboveThis: 0 },
    { base: 0.05, scaled: 0.05, penaltyAboveThis: 0 },
    { base: 0.1, scaled: 0.1, penaltyAboveThis: 0 },
    { base: 0.15, scaled: 0.14, penaltyAboveThis: 0.2 },
    { base: 0.20, scaled: 0.17, penaltyAboveThis: 0.4 },
    { base: 0.25, scaled: 0.19, penaltyAboveThis: 0.6 },
    { base: 1, scaled: 0.49, penaltyAboveThis: 1 },
  ];

  constructor(...args) {
    super(...args);
    // TODO: Use combatantinfo event directly
    this._pullStats = {
      strength: this.selectedCombatant._combatantInfo.strength,
      agility: this.selectedCombatant._combatantInfo.agility,
      intellect: this.selectedCombatant._combatantInfo.intellect,
      stamina: this.selectedCombatant._combatantInfo.stamina,
      crit: this.selectedCombatant._combatantInfo.critSpell,
      haste: this.selectedCombatant._combatantInfo.hasteSpell || 0, // the || 0 fixes tests where combatantinfo may not be defined
      mastery: this.selectedCombatant._combatantInfo.mastery,
      versatility: this.selectedCombatant._combatantInfo.versatilityHealingDone,
      avoidance: this.selectedCombatant._combatantInfo.avoidance,
      leech: this.selectedCombatant._combatantInfo.leech,
      speed: this.selectedCombatant._combatantInfo.speed,
      armor: this.selectedCombatant._combatantInfo.armor,
    };

    this.applySpecModifiers();

    this.statBuffs = {
      ...this.constructor.DEFAULT_BUFFS,
    };

    this._currentStats = {
      ...this._pullStats,
    };

    // Really hoping people don't run around with wrong armor types
    this.addStatMultiplier({
      intellect: 1 + ARMOR_INT_BONUS,
      strength: 1 + ARMOR_INT_BONUS,
      agility: 1 + ARMOR_INT_BONUS,
    });

    this.addEventListener(Events.changebuffstack.to(SELECTED_PLAYER), this.onChangeBuffStack);
    this.addEventListener(Events.changedebuffstack.to(SELECTED_PLAYER), this.onChangeDebuffStack);
    this.addEventListener(Events.cast.by(SELECTED_PLAYER), this.onCast);
    this.addEventListener(Events.heal.to(SELECTED_PLAYER), this.onHealTaken);


    debug && this._debugPrintStats(this._currentStats);
  }

  /**
   * Adds a stat buff to StatTracker.
   * @param buffId ID of the stat buff
   * @param stats Object with stats (intellect, mastery, haste, crit etc.) and their respective bonus (either fixed value or a function (combatant, item) => value). If it's from item, provide also an itemId (item in the stat callback is taken from this itemId).
   */
  add(buffId, stats) {
    if (!buffId || !stats) {
      throw new Error(`StatTracker.add() called with invalid buffId ${buffId} or stats`);
    }
    if (typeof buffId === 'object') {
      buffId = buffId.id;
    }
    if (this.statBuffs[buffId]) {
      throw new Error(`Stat buff with ID ${buffId} already exists`);
    }
    // if any stat's function uses the item argument, validate that itemId property exists
    debug && this.log(`StatTracker.add(), buffId: ${buffId}, stats:`, stats);
    const usesItemArgument = Object.values(stats).some(value => typeof value === 'function' && value.length === 2);
    if (usesItemArgument && !stats.itemId) {
      throw new Error(`Stat buff ${buffId} uses item argument, but does not provide item ID`);
    }
    this.statBuffs[buffId] = stats;
  }

  update(buffId, stats) {
    if (!buffId || !stats) {
      throw new Error(`StatTracker.update() called with invalid buffId ${buffId} or stats`);
    }
    if (typeof buffId === 'object') {
      buffId = buffId.id;
    }
    if (!this.statBuffs[buffId]) {
      throw new Error(`Stat buff with ID ${buffId} doesn't exist, so it can't be updated - remember to add it first!`);
    }
    debug && this.log(`StatTracker.update(), buffId: ${buffId}, stats:`, stats);
    const usesItemArgument = Object.values(stats).some(value => typeof value === 'function' && value.length === 2);
    if (usesItemArgument && !stats.itemId) {
      throw new Error(`Stat buff ${buffId} uses item argument, but does not provide item ID`);
    }
    this.statBuffs[buffId] = stats;
  }

  addStatMultiplier(stats, changeCurrentStats = false) {
    const delta = {};
    for (const stat in stats) {
      const before = this.statMultiplier[stat];
      this.statMultiplier[stat] *= stats[stat];
      debug && console.log(`StatTracker: ${stat} multiplier change (${before.toFixed(2)} -> ${this.statMultiplier[stat].toFixed(2)}) @ ${formatMilliseconds(this.owner.fightDuration)}`);

      if (changeCurrentStats) {
        delta[stat] = Math.round(this._currentStats[stat] * stats[stat] - this._currentStats[stat]);
      }
    }

    changeCurrentStats && this.forceChangeStats(delta, null, true);
  }

  removeStatMultiplier(stats, changeCurrentStats = false) {
    const delta = {};
    for (const stat in stats) {
      const before = this.statMultiplier[stat];
      this.statMultiplier[stat] /= stats[stat];

      debug && console.log(`StatTracker: ${stat} multiplier change (${before.toFixed(2)} -> ${this.statMultiplier[stat].toFixed(2)}) @ ${formatMilliseconds(this.owner.fightDuration)}`);

      if (changeCurrentStats) {
        delta[stat] = Math.round(this._currentStats[stat] / stats[stat] - this._currentStats[stat]);
      }
    }

    changeCurrentStats && this.forceChangeStats(delta, null, true);
  }

  applySpecModifiers() {
    const modifiers = this.constructor.SPEC_MULTIPLIERS[this.selectedCombatant.spec.id] || {};
    Object.entries(modifiers).forEach(([stat, multiplier]) => {
      this._pullStats[stat] *= multiplier;
    });
  }

  /*
   * Stat rating at pull.
   * Should be identical to what you get from Combatant.
   */
  get startingStrengthRating() {
    return this._pullStats.strength;
  }

  get startingAgilityRating() {
    return this._pullStats.agility;
  }

  get startingIntellectRating() {
    return this._pullStats.intellect;
  }

  get startingStaminaRating() {
    return this._pullStats.stamina;
  }

  get startingCritRating() {
    return this._pullStats.crit;
  }

  get startingHasteRating() {
    return this._pullStats.haste;
  }

  get startingMasteryRating() {
    return this._pullStats.mastery;
  }

  get startingVersatilityRating() {
    return this._pullStats.versatility;
  }

  get startingAvoidanceRating() {
    return this._pullStats.avoidance;
  }

  get startingLeechRating() {
    return this._pullStats.leech;
  }

  get startingSpeedRating() {
    return this._pullStats.speed;
  }

  get startingArmorRating() {
    return this._pullStats.armor;
  }

  /*
   * Current stat rating, as tracked by this module.
   */
  get currentStrengthRating() {
    return this._currentStats.strength;
  }

  get currentAgilityRating() {
    return this._currentStats.agility;
  }

  get currentIntellectRating() {
    return this._currentStats.intellect;
  }

  get currentStaminaRating() {
    return this._currentStats.stamina;
  }

  get currentCritRating() {
    return this._currentStats.crit;
  }

  get currentHasteRating() {
    return this._currentStats.haste;
  }

  get currentMasteryRating() {
    return this._currentStats.mastery;
  }

  get currentVersatilityRating() {
    return this._currentStats.versatility;
  }

  get currentAvoidanceRating() {
    return this._currentStats.avoidance;
  }

  get currentLeechRating() {
    return this._currentStats.leech;
  }

  get currentSpeedRating() {
    return this._currentStats.speed;
  }

  get currentArmorRating() {
    return this._currentStats.armor;
  }

  /*
   * For percentage stats, the percentage you'd have with zero rating.
   * These values don't change.
   */
  get baseCritPercentage() {
    let critChance = 0.05;
    if (this.selectedCombatant.race === RACES.BloodElf) {
      critChance += 0.01;
    }
    switch (this.selectedCombatant.spec) {
      case SPECS.FIRE_MAGE:
        return critChance + 0.15; // an additional 15% is gained from the passive Critical Mass
      case SPECS.BEAST_MASTERY_HUNTER:
        return critChance + 0.05; //baseline +5%
      case SPECS.MARKSMANSHIP_HUNTER:
        return critChance + 0.05; //baseline +5%
      case SPECS.SURVIVAL_HUNTER:
        return critChance + 0.06; //baseline +6%
      case SPECS.BREWMASTER_MONK:
        return critChance + 0.05; //baseline +5%
      case SPECS.WINDWALKER_MONK:
        return critChance + 0.05; //baseline +5%
      case SPECS.HAVOC_DEMON_HUNTER:
        return critChance + 0.06; //baseline +6%
      case SPECS.SUBTLETY_ROGUE:
        return critChance + 0.05; //baseline +5%
      case SPECS.ASSASSINATION_ROGUE:
        return critChance + 0.05; //baseline +5%
      case SPECS.OUTLAW_ROGUE:
        return critChance + 0.05; //baseline +5%
      default:
        return critChance;
    }
  }

  get baseHastePercentage() {<commit_msg>[Core] Update base crit values for all specs<commit_after>import SPELLS from 'common/SPELLS/index';
import ITEMS from 'common/ITEMS/index';
import { calculateSecondaryStatDefault } from 'common/stats';
import { formatMilliseconds } from 'common/format';
import SPECS from 'game/SPECS';
import RACES from 'game/RACES';
import Analyzer, { SELECTED_PLAYER } from 'parser/core/Analyzer';
import EventEmitter from 'parser/core/modules/EventEmitter';
import Events, { EventType } from 'parser/core/Events';
import STAT from 'parser/shared/modules/features/STAT';

const ARMOR_INT_BONUS = .05;

const debug = false;

// TODO: stat constants somewhere else? they're largely copied from combatant
class StatTracker extends Analyzer {
  static dependencies = {
    eventEmitter: EventEmitter,
  };

  // These are multipliers to the stats applied *on pull* that are not
  // included in the stats reported by WCL. These are *baked in* and do
  // not multiply temporary buffs.
  //
  // In general, it looks like armor is the only one that isn't applied
  // by WCL.
  static SPEC_MULTIPLIERS = {
    [SPECS.BREWMASTER_MONK.id]: { armor: 1.25 },
  };

  static DEFAULT_BUFFS = {
    // region Potions
    [SPELLS.POTION_OF_SPECTRAL_AGILITY.id]: { agility: 190 },
    [SPELLS.POTION_OF_SPECTRAL_INTELLECT.id]: { intellect: 190 },
    [SPELLS.POTION_OF_SPECTRAL_STRENGTH.id]: { strength: 190 },
    [SPELLS.POTION_OF_SPECTRAL_STAMINA.id]: { stamina: 285 },
    [SPELLS.POTION_OF_HARDENED_SHADOWS.id]: { armor: 275 },
    // endregion

    // region Runes
    [SPELLS.VEILED_AUGMENT_RUNE.id]: { strength: 18, agility: 18, intellect: 18 },
    // endregion

    //region Flasks
    [SPELLS.FLASK_OF_THE_CURRENTS.id]: { agility: 25 },
    [SPELLS.FLASK_OF_ENDLESS_FATHOMS.id]: { intellect: 25 },
    [SPELLS.FLASK_OF_THE_UNDERTOW.id]: { strength: 25 },
    [SPELLS.FLASK_OF_THE_VAST_HORIZON.id]: { stamina: 38 },
    [SPELLS.GREATER_FLASK_OF_THE_CURRENTS.id]: { agility: 38 },
    [SPELLS.GREATER_FLASK_OF_ENDLESS_FATHOMS.id]: { intellect: 38 },
    [SPELLS.GREATER_FLASK_OF_THE_UNDERTOW.id]: { strength: 38 },
    [SPELLS.GREATER_FLASK_OF_THE_VAST_HORIZON.id]: { stamina: 57 },
    [SPELLS.SPECTRAL_FLASK_OF_POWER.id]: { strength: 70, agility: 70, intellect: 70 },
    [SPELLS.SPECTRAL_FLASK_OF_STAMINA.id]: { stamina: 105 },
    // endregion

    //region Food
    [SPELLS.BOUNTIFUL_CAPTAIN_FEAST_INT.id]: { intellect: 15 },
    [SPELLS.BOUNTIFUL_CAPTAIN_FEAST_STR.id]: { strength: 15 },
    [SPELLS.BOUNTIFUL_CAPTAIN_FEAST_AGI.id]: { agility: 15 },
    [SPELLS.FAMINE_EVALUATOR_AND_SNACK_TABLE_FEAST_INT.id]: { intellect: 16 },
    [SPELLS.FAMINE_EVALUATOR_AND_SNACK_TABLE_FEAST_STR.id]: { strength: 16 },
    [SPELLS.FAMINE_EVALUATOR_AND_SNACK_TABLE_FEAST_AGI.id]: { agility: 16 },
    [SPELLS.ABYSSAL_FRIED_RISSOLE.id]: { mastery: 14 },
    [SPELLS.BIL_TONG.id]: { versatility: 14 },
    [SPELLS.MECH_DOWELS_BIG_MECH.id]: { crit: 14 },
    [SPELLS.BAKED_PORT_TATO.id]: { haste: 14 },
    [SPELLS.FRAGRANT_KAKAVIA.id]: { stamina: 29 },
    [SPELLS.BORALUS_BLOOD_SAUSAGE_AGI.id]: { agility: 13 },
    [SPELLS.BORALUS_BLOOD_SAUSAGE_INT.id]: { intellect: 13 },
    [SPELLS.BORALUS_BLOOD_SAUSAGE_STR.id]: { strength: 13 },
    [SPELLS.WELL_FED_SEASONED_STEAK_AND_POTATOES.id]: { stamina: 22 },

    [SPELLS.PICKLED_MEAT_SMOOTHIE.id]: { stamina: 14 },
    [SPELLS.SWEET_SILVERGILL_SAUSAGES.id]: { versatility: 18 },
    [SPELLS.BUTTERSCOTCH_MARINATED_RIBS.id]: { crit: 18 },
    [SPELLS.MEATY_APPLE_DUMPLINGS.id]: { mastery: 18 },
    [SPELLS.CINNAMON_BONEFISH_STEW.id]: { haste: 18 },
    [SPELLS.BANANA_BEEF_PUDDING.id]: { stamina: 22 },
    [SPELLS.STEAK_A_LA_MODE.id]: { versatility: 30 },
    [SPELLS.TENEBROUS_CROWN_ROAST_ASPIC.id]: { haste: 30 },
    [SPELLS.SPINEFIN_SOUFFLE_AND_FRIES.id]: { crit: 30 },
    [SPELLS.IRIDESCENT_RAVIOLI_WITH_APPLE_SAUCE.id]: { mastery: 30 },
    [SPELLS.SURPRISINGLY_PALATABLE_FEAST_INT.id]: { intellect: 18 },
    [SPELLS.SURPRISINGLY_PALATABLE_FEAST_STR.id]: { strength: 18 },
    [SPELLS.SURPRISINGLY_PALATABLE_FEAST_AGI.id]: { agility: 18 },
    [SPELLS.FEAST_OF_GLUTTONOUS_HEDONISM_INT.id]: { intellect: 20 },
    [SPELLS.FEAST_OF_GLUTTONOUS_HEDONISM_STR.id]: { strength: 20 },
    [SPELLS.FEAST_OF_GLUTTONOUS_HEDONISM_AGI.id]: { agility: 20 },
    //endregion

    // region Misc
    [SPELLS.JACINS_RUSE.id]: { mastery: 48 },
    [SPELLS.MARK_OF_THE_CLAW.id]: { crit: 45, haste: 45 },
    // endregion

    // region Death Knight
    // endregion

    // region Druid
    [SPELLS.ASTRAL_HARMONY.id]: { mastery: 181 },
    // endregion

    // region Mage

    // endregion

    /****************************************\
     *               SHADOWLANDS:             *
     \****************************************/

     //Trinkets
     [SPELLS.INSCRUTABLE_QUANTUM_DEVICE_CRIT.id]: {
      itemId: ITEMS.INSCRUTABLE_QUANTUM_DEVICE.id,
      crit: (_, item) => calculateSecondaryStatDefault(184, 568, item.itemLevel),
    },
    [SPELLS.INSCRUTABLE_QUANTUM_DEVICE_HASTE.id]: {
      itemId: ITEMS.INSCRUTABLE_QUANTUM_DEVICE.id,
      haste: (_, item) => calculateSecondaryStatDefault(184, 568, item.itemLevel),
    },
    [SPELLS.INSCRUTABLE_QUANTUM_DEVICE_MASTERY.id]: {
      itemId: ITEMS.INSCRUTABLE_QUANTUM_DEVICE.id,
      mastery: (_, item) => calculateSecondaryStatDefault(184, 568, item.itemLevel),
    },
    [SPELLS.INSCRUTABLE_QUANTUM_DEVICE_VERS.id]: {
      itemId: ITEMS.INSCRUTABLE_QUANTUM_DEVICE.id,
      versatility: (_, item) => calculateSecondaryStatDefault(184, 568, item.itemLevel),
    },

    //endregion
    //endregion

    // region Racials
    // <NAME>
    [SPELLS.RICTUS_OF_THE_LAUGHING_SKULL.id]: { crit: 125 },
    [SPELLS.ZEAL_OF_THE_BURNING_BLADE.id]: { haste: 125 },
    [SPELLS.FEROCITY_OF_THE_FROSTWOLF.id]: { mastery: 125 },
    [SPELLS.MIGHT_OF_THE_BLACKROCK.id]: { versatility: 125 },
    // endregion
  };

  statBuffs = {};

  _pullStats = {};
  _currentStats = {};

  statMultiplier = {
    strength: 1,
    agility: 1,
    intellect: 1,
    stamina: 1,
    crit: 1,
    haste: 1, // should usually be done through the haste module
    mastery: 1,
    versatility: 1,
    avoidance: 1,
    leech: 1,
    speed: 1,
    armor: 1,
  };
  statMultiplierBuffs = {
    [SPELLS.ARCANE_INTELLECT.id]: { intellect: 1.05 },
    [SPELLS.BATTLE_SHOUT.id]: { strength: 1.05, agility: 1.05 },
  };

  //Values taken from https://github.com/simulationcraft/simc/blob/shadowlands/engine/dbc/generated/sc_scale_data.inc
  statBaselineRatingPerPercent = {
    /** Secondaries */
    [STAT.CRITICAL_STRIKE]: 35,
    [STAT.HASTE]: 33,
    [STAT.MASTERY]: 35,
    [STAT.VERSATILITY]: 40,
    /** Tertiaries */
    [STAT.AVOIDANCE]: 14,
    [STAT.LEECH]: 21,
    [STAT.SPEED]: 10,
  };

  /** Secondary stat scaling thresholds
   * Taken from https://raw.githubusercontent.com/simulationcraft/simc/shadowlands/engine/dbc/generated/item_scaling.inc
   * Search for 21024 in the first column for secondary stat scaling
   */
  secondaryStatPenaltyThresholds = [
    /** Secondary stat scaling thresholds */
    { base: 0, scaled: 0, penaltyAboveThis: 0 },
    { base: 0.3, scaled: 0.3, penaltyAboveThis: 0.1 },
    { base: 0.4, scaled: 0.39, penaltyAboveThis: 0.2 },
    { base: 0.5, scaled: 0.47, penaltyAboveThis: 0.3 },
    { base: 0.6, scaled: 0.54, penaltyAboveThis: 0.4 },
    { base: 0.8, scaled: 0.66, penaltyAboveThis: 0.5 },
    { base: 1, scaled: 0.76, penaltyAboveThis: 0.5 },
    { base: 2, scaled: 1.26, penaltyAboveThis: 1 },
  ];

  /** Tertiary stat scaling thresholds
   * Taken from https://raw.githubusercontent.com/simulationcraft/simc/shadowlands/engine/dbc/generated/item_scaling.inc
   * Search for 21025 in the first column for tertiary stat scaling
   */
  tertiaryStatPenaltyThresholds = [
    /** Tertiary stat scaling thresholds */
    { base: 0, scaled: 0, penaltyAboveThis: 0 },
    { base: 0.05, scaled: 0.05, penaltyAboveThis: 0 },
    { base: 0.1, scaled: 0.1, penaltyAboveThis: 0 },
    { base: 0.15, scaled: 0.14, penaltyAboveThis: 0.2 },
    { base: 0.20, scaled: 0.17, penaltyAboveThis: 0.4 },
    { base: 0.25, scaled: 0.19, penaltyAboveThis: 0.6 },
    { base: 1, scaled: 0.49, penaltyAboveThis: 1 },
  ];

  constructor(...args) {
    super(...args);
    // TODO: Use combatantinfo event directly
    this._pullStats = {
      strength: this.selectedCombatant._combatantInfo.strength,
      agility: this.selectedCombatant._combatantInfo.agility,
      intellect: this.selectedCombatant._combatantInfo.intellect,
      stamina: this.selectedCombatant._combatantInfo.stamina,
      crit: this.selectedCombatant._combatantInfo.critSpell,
      haste: this.selectedCombatant._combatantInfo.hasteSpell || 0, // the || 0 fixes tests where combatantinfo may not be defined
      mastery: this.selectedCombatant._combatantInfo.mastery,
      versatility: this.selectedCombatant._combatantInfo.versatilityHealingDone,
      avoidance: this.selectedCombatant._combatantInfo.avoidance,
      leech: this.selectedCombatant._combatantInfo.leech,
      speed: this.selectedCombatant._combatantInfo.speed,
      armor: this.selectedCombatant._combatantInfo.armor,
    };

    this.applySpecModifiers();

    this.statBuffs = {
      ...this.constructor.DEFAULT_BUFFS,
    };

    this._currentStats = {
      ...this._pullStats,
    };

    // Really hoping people don't run around with wrong armor types
    this.addStatMultiplier({
      intellect: 1 + ARMOR_INT_BONUS,
      strength: 1 + ARMOR_INT_BONUS,
      agility: 1 + ARMOR_INT_BONUS,
    });

    this.addEventListener(Events.changebuffstack.to(SELECTED_PLAYER), this.onChangeBuffStack);
    this.addEventListener(Events.changedebuffstack.to(SELECTED_PLAYER), this.onChangeDebuffStack);
    this.addEventListener(Events.cast.by(SELECTED_PLAYER), this.onCast);
    this.addEventListener(Events.heal.to(SELECTED_PLAYER), this.onHealTaken);


    debug && this._debugPrintStats(this._currentStats);
  }

  /**
   * Adds a stat buff to StatTracker.
   * @param buffId ID of the stat buff
   * @param stats Object with stats (intellect, mastery, haste, crit etc.) and their respective bonus (either fixed value or a function (combatant, item) => value). If it's from item, provide also an itemId (item in the stat callback is taken from this itemId).
   */
  add(buffId, stats) {
    if (!buffId || !stats) {
      throw new Error(`StatTracker.add() called with invalid buffId ${buffId} or stats`);
    }
    if (typeof buffId === 'object') {
      buffId = buffId.id;
    }
    if (this.statBuffs[buffId]) {
      throw new Error(`Stat buff with ID ${buffId} already exists`);
    }
    // if any stat's function uses the item argument, validate that itemId property exists
    debug && this.log(`StatTracker.add(), buffId: ${buffId}, stats:`, stats);
    const usesItemArgument = Object.values(stats).some(value => typeof value === 'function' && value.length === 2);
    if (usesItemArgument && !stats.itemId) {
      throw new Error(`Stat buff ${buffId} uses item argument, but does not provide item ID`);
    }
    this.statBuffs[buffId] = stats;
  }

  update(buffId, stats) {
    if (!buffId || !stats) {
      throw new Error(`StatTracker.update() called with invalid buffId ${buffId} or stats`);
    }
    if (typeof buffId === 'object') {
      buffId = buffId.id;
    }
    if (!this.statBuffs[buffId]) {
      throw new Error(`Stat buff with ID ${buffId} doesn't exist, so it can't be updated - remember to add it first!`);
    }
    debug && this.log(`StatTracker.update(), buffId: ${buffId}, stats:`, stats);
    const usesItemArgument = Object.values(stats).some(value => typeof value === 'function' && value.length === 2);
    if (usesItemArgument && !stats.itemId) {
      throw new Error(`Stat buff ${buffId} uses item argument, but does not provide item ID`);
    }
    this.statBuffs[buffId] = stats;
  }

  addStatMultiplier(stats, changeCurrentStats = false) {
    const delta = {};
    for (const stat in stats) {
      const before = this.statMultiplier[stat];
      this.statMultiplier[stat] *= stats[stat];
      debug && console.log(`StatTracker: ${stat} multiplier change (${before.toFixed(2)} -> ${this.statMultiplier[stat].toFixed(2)}) @ ${formatMilliseconds(this.owner.fightDuration)}`);

      if (changeCurrentStats) {
        delta[stat] = Math.round(this._currentStats[stat] * stats[stat] - this._currentStats[stat]);
      }
    }

    changeCurrentStats && this.forceChangeStats(delta, null, true);
  }

  removeStatMultiplier(stats, changeCurrentStats = false) {
    const delta = {};
    for (const stat in stats) {
      const before = this.statMultiplier[stat];
      this.statMultiplier[stat] /= stats[stat];

      debug && console.log(`StatTracker: ${stat} multiplier change (${before.toFixed(2)} -> ${this.statMultiplier[stat].toFixed(2)}) @ ${formatMilliseconds(this.owner.fightDuration)}`);

      if (changeCurrentStats) {
        delta[stat] = Math.round(this._currentStats[stat] / stats[stat] - this._currentStats[stat]);
      }
    }

    changeCurrentStats && this.forceChangeStats(delta, null, true);
  }

  applySpecModifiers() {
    const modifiers = this.constructor.SPEC_MULTIPLIERS[this.selectedCombatant.spec.id] || {};
    Object.entries(modifiers).forEach(([stat, multiplier]) => {
      this._pullStats[stat] *= multiplier;
    });
  }

  /*
   * Stat rating at pull.
   * Should be identical to what you get from Combatant.
   */
  get startingStrengthRating() {
    return this._pullStats.strength;
  }

  get startingAgilityRating() {
    return this._pullStats.agility;
  }

  get startingIntellectRating() {
    return this._pullStats.intellect;
  }

  get startingStaminaRating() {
    return this._pullStats.stamina;
  }

  get startingCritRating() {
    return this._pullStats.crit;
  }

  get startingHasteRating() {
    return this._pullStats.haste;
  }

  get startingMasteryRating() {
    return this._pullStats.mastery;
  }

  get startingVersatilityRating() {
    return this._pullStats.versatility;
  }

  get startingAvoidanceRating() {
    return this._pullStats.avoidance;
  }

  get startingLeechRating() {
    return this._pullStats.leech;
  }

  get startingSpeedRating() {
    return this._pullStats.speed;
  }

  get startingArmorRating() {
    return this._pullStats.armor;
  }

  /*
   * Current stat rating, as tracked by this module.
   */
  get currentStrengthRating() {
    return this._currentStats.strength;
  }

  get currentAgilityRating() {
    return this._currentStats.agility;
  }

  get currentIntellectRating() {
    return this._currentStats.intellect;
  }

  get currentStaminaRating() {
    return this._currentStats.stamina;
  }

  get currentCritRating() {
    return this._currentStats.crit;
  }

  get currentHasteRating() {
    return this._currentStats.haste;
  }

  get currentMasteryRating() {
    return this._currentStats.mastery;
  }

  get currentVersatilityRating() {
    return this._currentStats.versatility;
  }

  get currentAvoidanceRating() {
    return this._currentStats.avoidance;
  }

  get currentLeechRating() {
    return this._currentStats.leech;
  }

  get currentSpeedRating() {
    return this._currentStats.speed;
  }

  get currentArmorRating() {
    return this._currentStats.armor;
  }

  /*
   * For percentage stats, the percentage you'd have with zero rating.
   * These values don't change.
   */
  get baseCritPercentage() {
    let critChance = 0.05;
    if (this.selectedCombatant.race === RACES.BloodElf) {
      critChance += 0.01;
    }
    switch (this.selectedCombatant.spec) {
      case SPECS.FIRE_MAGE:
        return critChance + 0.15; // an additional 15% is gained from the passive Critical Mass
      case SPECS.HAVOC_DEMON_HUNTER:
      case SPECS.VENGEANCE_DEMON_HUNTER:
      case SPECS.GUARDIAN_DRUID:
      case SPECS.FERAL_DRUID:
      case SPECS.SURVIVAL_HUNTER:
      case SPECS.BEAST_MASTERY_HUNTER:
      case SPECS.MARKSMANSHIP_HUNTER:
      case SPECS.BREWMASTER_MONK:
      case SPECS.WINDWALKER_MONK:
      case SPECS.ENHANCEMENT_SHAMAN:
      case SPECS.SUBTLETY_ROGUE:
      case SPECS.ASSASSINATION_ROGUE:
      case SPECS.OUTLAW_ROGUE:
        return critChance + 0.05; //baseline +5%
      default:
        return critChance;
    }
  }

  get baseHastePercentage() {
{'fraction_non_alphanumeric': 0.13120956914439155, 'fraction_numerical': 0.02056633711705114, 'mean_word_length': 3.4049731182795697, 'pattern_counts': {'":': 0, '<': 5, '<?xml version=': 0, '>': 25, 'https://': 6, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '1676890', 'n_tokens_mistral': 12552, 'n_tokens_neox': 11491, 'n_words': 2257}