From 612151e38622df4ef9b32bb1c65606b2c532aa4a Mon Sep 17 00:00:00 2001 From: Richard Harrah Date: Wed, 27 Mar 2024 00:33:31 -0400 Subject: [PATCH 1/2] rewrite EventsTab in TypeScript and as a functional component! --- src/CHANGELOG.tsx | 1 + src/CONTRIBUTORS.ts | 2 +- src/interface/EventsTab.jsx | 576 ---------------------------------- src/interface/EventsTab.tsx | 608 ++++++++++++++++++++++++++++++++++++ 4 files changed, 610 insertions(+), 577 deletions(-) delete mode 100644 src/interface/EventsTab.jsx create mode 100644 src/interface/EventsTab.tsx diff --git a/src/CHANGELOG.tsx b/src/CHANGELOG.tsx index fb922de176b..245a046ab9f 100644 --- a/src/CHANGELOG.tsx +++ b/src/CHANGELOG.tsx @@ -34,6 +34,7 @@ import SpellLink from 'interface/SpellLink'; // prettier-ignore export default [ + change(date(2024, 3, 27), 'Rewrite events tab in TypeScript.', ToppleTheNun), change(date(2024, 3, 26), 'Add patch 10.2.6.', ToppleTheNun), change(date(2024, 3, 26), 'Add Dragonflight season 4 M+ dungeons and zone.', ToppleTheNun), change(date(2024, 3, 26), 'Remove support for Shadowlands tier sets.', ToppleTheNun), diff --git a/src/CONTRIBUTORS.ts b/src/CONTRIBUTORS.ts index ee58bd46939..c76b391844b 100644 --- a/src/CONTRIBUTORS.ts +++ b/src/CONTRIBUTORS.ts @@ -1976,7 +1976,7 @@ export const ToppleTheNun: Contributor = { nickname: 'ToppleTheNun', github: 'ToppleTheNun', avatar: avatar('ToppleTheNun-avatar.jpg'), - discord: 'ToppleTheNun#6969', + discord: 'ToppleTheNun', mains: [ { name: 'Toppledh', diff --git a/src/interface/EventsTab.jsx b/src/interface/EventsTab.jsx deleted file mode 100644 index bf8f0f6e368..00000000000 --- a/src/interface/EventsTab.jsx +++ /dev/null @@ -1,576 +0,0 @@ -import { formatDuration, formatThousands } from 'common/format'; -import HIT_TYPES from 'game/HIT_TYPES'; -import RESOURCE_TYPES from 'game/RESOURCE_TYPES'; -import Icon from 'interface/Icon'; -import InformationIcon from 'interface/icons/Information'; -import SpellLink from 'interface/SpellLink'; -import Tooltip, { TooltipElement } from 'interface/Tooltip'; -import { EventType } from 'parser/core/Events'; -import PropTypes from 'prop-types'; -import { Component } from 'react'; -import Toggle from 'react-toggle'; -import AutoSizer from 'react-virtualized/dist/commonjs/AutoSizer'; -import Table, { - defaultRowRenderer as defaultTableRowRenderer, - Column, -} from 'react-virtualized/dist/commonjs/Table'; - -import 'react-toggle/style.css'; -import 'react-virtualized/styles.css'; -import './EventsTab.css'; - -const FILTERABLE_TYPES = { - damage: { - name: 'Damage', - }, - heal: { - name: 'Heal', - }, - healabsorbed: { - name: 'Heal Absorbed', - explanation: - 'Triggered in addition to the regular heal event whenever a heal is absorbed. Can be used to determine what buff or debuff was absorbing the healing. This should only be used if you need to know which ability soaked the healing.', - }, - absorbed: { - name: 'Absorb', - explanation: - 'Triggered whenever an absorb effect absorbs damage. These are friendly shields to avoid damage and NOT healing absorption shields.', - }, - begincast: { - name: 'Begin Cast', - }, - cast: { - name: 'Cast Success', - explanation: - 'Triggered whenever a cast was successful. Blizzard also sometimes uses this event type for mechanics and spell ticks or bolts.', - }, - freecast: { - name: 'Free Cast', - explanation: 'Casts that we have detected might have been cast "for free."', - }, - empowerstart: { - name: 'Empower Start', - }, - empowerend: { - name: 'Empower End', - }, - applybuff: { - name: 'Buff Apply', - }, - removebuff: { - name: 'Buff Remove', - }, - applybuffstack: { - name: 'Buff Stack Gained', - }, - removebuffstack: { - name: 'Buff Stack Lost', - }, - refreshbuff: { - name: 'Buff Refresh', - }, - applydebuff: { - name: 'Debuff Apply', - }, - removedebuff: { - name: 'Debuff Remove', - }, - applydebuffstack: { - name: 'Debuff Stack Gained', - }, - removedebuffstack: { - name: 'Debuff Stack Lost', - }, - refreshdebuff: { - name: 'Debuff Refresh', - }, - summon: { - name: 'Summon', - }, - combatantinfo: { - name: 'Player Info', - explanation: - 'Triggered at the start of the fight with advanced combat logging on. This includes gear, talents, etc.', - }, - resourcechange: { - name: 'Resource Change', - }, - drain: { - name: 'Drain', - }, - interrupt: { - name: 'Interrupt', - }, - death: { - name: 'Death', - }, - resurrect: { - name: 'Resurrect', - }, - dispel: { - name: 'Dispel', - }, - aurabroken: { - name: 'Aura Broken', - }, - leech: { - name: 'Leech', - }, - create: { - name: 'Create', - }, - extraattacks: { - name: 'Extra Attacks', - explanation: 'Typically triggered by Windfury Totem.', - }, -}; - -class EventsTab extends Component { - static propTypes = { - parser: PropTypes.object.isRequired, - }; - - constructor() { - super(); - this.state = { - ...Object.keys(FILTERABLE_TYPES).reduce((obj, type) => { - obj[type] = true; - return obj; - }, {}), - rawNames: false, - showFabricated: false, - search: '', - }; - this.handleRowClick = this.handleRowClick.bind(this); - this.toggleAllOff = Object.keys(FILTERABLE_TYPES).reduce((obj, type) => { - obj[type] = false; - return obj; - }, {}); - } - - findEntity(id) { - const friendly = this.props.parser.report.friendlies.find((friendly) => friendly.id === id); - if (friendly) { - return friendly; - } - const enemy = this.props.parser.report.enemies.find((enemy) => enemy.id === id); - if (enemy) { - return enemy; - } - const enemyPet = this.props.parser.report.enemyPets.find((enemyPet) => enemyPet.id === id); - if (enemyPet) { - return enemyPet; - } - const pet = this.props.parser.playerPets.find((pet) => pet.id === id); - if (pet) { - return pet; - } - return null; - } - - renderEntity(entity) { - if (!entity) { - return null; - } - return {entity.name}; - } - - renderAbility(ability) { - if (!ability) { - return null; - } - const spellId = ability.guid; - - return ( - <> - - {ability.abilityIcon && } {ability.name} - {' '} - ID: {spellId} - - ); - } - - eventTypeName(type) { - return this.state.rawNames ? type : FILTERABLE_TYPES[type] ? FILTERABLE_TYPES[type].name : type; - } - - renderEventTypeToggle(type) { - const name = this.eventTypeName(type); - const explanation = FILTERABLE_TYPES[type] ? FILTERABLE_TYPES[type].explanation : undefined; - return this.renderToggle(type, name, explanation); - } - - renderToggle(prop, label, explanation = null) { - return ( -
- - {explanation && ( -
- -
- -
-
-
- )} - this.setState({ [prop]: event.target.checked })} - id={`${prop}-toggle`} - className="flex-sub" - /> -
- ); - } - - renderRow(props) { - const event = props.rowData; - return defaultTableRowRenderer({ - ...props, - className: `${props.className} ${event.__modified ? 'modified' : ''} ${ - event.__fabricated ? 'fabricated' : '' - } ${event.__reordered ? 'reordered' : ''}`, - }); - } - - handleRowClick({ rowData }) { - console.log(rowData); - } - - toggleAllFiltersOff() { - this.setState(this.toggleAllOff); - } - - renderSearchBox() { - return ( - this.setState({ search: event.target.value.trim().toLowerCase() })} - placeholder="Search events" - autoCorrect="off" - autoCapitalize="off" - spellCheck="false" - /> - ); - } - - render() { - const { parser } = this.props; - - const regex = /"([^"]*)"|(\S+)/g; - const searchTerms = (this.state.search.match(regex) || []).map((m) => m.replace(regex, '$1$2')); - - const events = parser.eventHistory.filter((event) => { - if (this.state[event.type] === false) { - return false; - } - if (!this.state.showFabricated && event.__fabricated === true) { - return false; - } - - // Search Logic - if (searchTerms.length === 0) { - return true; - } - - const source = this.findEntity(event.sourceID); - const target = this.findEntity(event.targetID); - - return searchTerms.some((searchTerm) => { - if (event.ability !== undefined) { - // noinspection EqualityComparisonWithCoercionJS - // eslint-disable-next-line eqeqeq - if (event.ability.guid == searchTerm) { - return true; - } else if (event.ability.name && event.ability.name.toLowerCase().includes(searchTerm)) { - return true; - } - } - if (source !== null && source.name.toLowerCase().includes(searchTerm)) { - return true; - } - if (target !== null && target.name.toLowerCase().includes(searchTerm)) { - return true; - } - if (event.type !== null && event.type.toLowerCase().includes(searchTerm)) { - return true; - } - return false; - }); - }); - - // TODO: Show active buffs like WCL - - return ( -
-
-

Events

- This only includes events involving the selected player. -
-
-
- {this.renderSearchBox()} -
- {Object.keys(FILTERABLE_TYPES).map((type) => this.renderEventTypeToggle(type))} -
-
- -
-
- {this.renderToggle( - 'showFabricated', - 'Fabricated events', - 'These events were not originally found in the combatlog. They were created by us to fix bugs, inconsistencies, or to provide new functionality. You can recognize these events by their green background.', - )} - {this.renderToggle('rawNames', 'Raw names')} -
-
- Events with an orange background were{' '} - - modified - - . -
-
- Events with a blue background were{' '} - - reordered - - . -
-
-
- - {({ width }) => ( - events[index]} - rowHeight={25} - rowRenderer={this.renderRow} - onRowClick={this.handleRowClick} - width={width} - > - - formatDuration( - cellData - parser.fight.start_time + parser.fight.offset_time, - 3, - ) - } - disableSort - width={25} - flexGrow={1} - /> - ( -
{this.eventTypeName(cellData)}
- )} - disableSort - width={50} - flexGrow={1} - /> - this.renderEntity(this.findEntity(cellData))} - disableSort - width={50} - flexGrow={1} - /> - this.renderEntity(this.findEntity(cellData))} - disableSort - width={50} - flexGrow={1} - /> - this.renderAbility(cellData)} - disableSort - width={160} - flexGrow={1} - /> - { - if (rowData.type === EventType.Damage) { - return ( - <> - - {formatThousands(rowData.amount)} - {' '} - {rowData.absorbed ? ( - - A: {formatThousands(rowData.absorbed)} - - ) : null}{' '} - Damage - - ); - } - if (rowData.type === EventType.Heal) { - return ( - <> - - {formatThousands(rowData.amount)} - {' '} - {rowData.absorbed ? ( - - A: {formatThousands(rowData.absorbed)} - - ) : null}{' '} - Healing - - ); - } - if (rowData.type === EventType.Absorbed) { - return ( - <> - {formatThousands(rowData.amount)}{' '} - Absorbed - - ); - } - if (rowData.type === EventType.ApplyBuff && rowData.absorb !== undefined) { - return ( - <> - Applied an absorb of{' '} - {formatThousands(rowData.absorb)}{' '} - Absorbed - - ); - } - if ( - rowData.type === EventType.ResourceChange || - rowData.type === EventType.Drain - ) { - const resource = RESOURCE_TYPES[rowData.resourceChangeType]; - const change = rowData.resourceChange - (rowData.waste || 0); - if (resource) { - return ( - <> - - {formatThousands(change)} {resource.name} - {' '} - {resource.icon && } - - ); - } - } - if ( - rowData.type === EventType.ApplyBuffStack || - rowData.type === EventType.ApplyDebuffStack || - rowData.type === EventType.RemoveBuffStack || - rowData.type === EventType.RemoveDebuffStack - ) { - const remove = - rowData.type === EventType.RemoveBuffStack || - rowData.type === EventType.RemoveDebuffStack; - return ( - <> - - {`\u2794 ${rowData.stack} stack${rowData.stack === 1 ? '' : 's'}`} - - - ); - } - return null; - }} - disableSort - width={60} - flexGrow={1} - /> - { - if (rowData.type === EventType.Damage) { - return ( - - {rowData.blocked ? ( - - B: {formatThousands(rowData.blocked)} - - ) : null} - - ); - } - if (rowData.type === EventType.Heal) { - return ( - - {rowData.overheal ? ( - - O: {formatThousands(rowData.overheal)} - - ) : null} - - ); - } - if (rowData.type === EventType.ResourceChange) { - const resource = RESOURCE_TYPES[rowData.resourceChangeType]; - if (resource) { - return ( - <> - - {rowData.waste > 0 - ? `${formatThousands(rowData.waste)} wasted` - : ''} - - - ); - } - } - return null; - }} - disableSort - width={25} - flexGrow={1} - /> -
- )} -
-
-
-
- ); - } -} - -export default EventsTab; diff --git a/src/interface/EventsTab.tsx b/src/interface/EventsTab.tsx new file mode 100644 index 00000000000..8bbf1c60bfa --- /dev/null +++ b/src/interface/EventsTab.tsx @@ -0,0 +1,608 @@ +import { formatDuration, formatThousands } from 'common/format'; +import HIT_TYPES from 'game/HIT_TYPES'; +import RESOURCE_TYPES from 'game/RESOURCE_TYPES'; +import Icon from 'interface/Icon'; +import InformationIcon from 'interface/icons/Information'; +import SpellLink from 'interface/SpellLink'; +import Tooltip, { TooltipElement } from 'interface/Tooltip'; +import { Ability, EventType, HasAbility, HasSource, HasTarget } from 'parser/core/Events'; +import { useReducer } from 'react'; +import Toggle from 'react-toggle'; +import { AutoSizer } from 'react-virtualized'; +import Table, { + Column, + defaultRowRenderer as defaultTableRowRenderer, +} from 'react-virtualized/dist/commonjs/Table'; + +import 'react-toggle/style.css'; +import 'react-virtualized/styles.css'; +import './EventsTab.css'; +import CombatLogParser from 'parser/core/CombatLogParser'; +import { PetInfo } from 'parser/core/Pet'; +import { EnemyInfo } from 'parser/core/Enemy'; +import { PlayerInfo } from 'parser/core/Player'; + +const FILTERABLE_TYPES = { + damage: { + name: 'Damage', + }, + heal: { + name: 'Heal', + }, + healabsorbed: { + name: 'Heal Absorbed', + explanation: + 'Triggered in addition to the regular heal event whenever a heal is absorbed. Can be used to determine what buff or debuff was absorbing the healing. This should only be used if you need to know which ability soaked the healing.', + }, + absorbed: { + name: 'Absorb', + explanation: + 'Triggered whenever an absorb effect absorbs damage. These are friendly shields to avoid damage and NOT healing absorption shields.', + }, + begincast: { + name: 'Begin Cast', + }, + cast: { + name: 'Cast Success', + explanation: + 'Triggered whenever a cast was successful. Blizzard also sometimes uses this event type for mechanics and spell ticks or bolts.', + }, + freecast: { + name: 'Free Cast', + explanation: 'Casts that we have detected might have been cast "for free."', + }, + empowerstart: { + name: 'Empower Start', + }, + empowerend: { + name: 'Empower End', + }, + applybuff: { + name: 'Buff Apply', + }, + removebuff: { + name: 'Buff Remove', + }, + applybuffstack: { + name: 'Buff Stack Gained', + }, + removebuffstack: { + name: 'Buff Stack Lost', + }, + refreshbuff: { + name: 'Buff Refresh', + }, + applydebuff: { + name: 'Debuff Apply', + }, + removedebuff: { + name: 'Debuff Remove', + }, + applydebuffstack: { + name: 'Debuff Stack Gained', + }, + removedebuffstack: { + name: 'Debuff Stack Lost', + }, + refreshdebuff: { + name: 'Debuff Refresh', + }, + summon: { + name: 'Summon', + }, + combatantinfo: { + name: 'Player Info', + explanation: + 'Triggered at the start of the fight with advanced combat logging on. This includes gear, talents, etc.', + }, + resourcechange: { + name: 'Resource Change', + }, + drain: { + name: 'Drain', + }, + interrupt: { + name: 'Interrupt', + }, + death: { + name: 'Death', + }, + resurrect: { + name: 'Resurrect', + }, + dispel: { + name: 'Dispel', + }, + aurabroken: { + name: 'Aura Broken', + }, + leech: { + name: 'Leech', + }, + create: { + name: 'Create', + }, + extraattacks: { + name: 'Extra Attacks', + explanation: 'Typically triggered by Windfury Totem.', + }, +} as const; +type FilterableEventType = keyof typeof FILTERABLE_TYPES; +const filterableEventTypes = Object.keys(FILTERABLE_TYPES); +const isFilterableEventType = (str: string): str is FilterableEventType => + filterableEventTypes.includes(str); + +const allFiltersOn = Object.entries(FILTERABLE_TYPES).reduce( + (acc, [k]) => ({ ...acc, [k]: true }), + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + {} as Record, +); +const allFiltersOff = Object.entries(FILTERABLE_TYPES).reduce( + (acc, [k]) => ({ ...acc, [k]: false }), + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + {} as Record, +); + +const findEntity = (parser: CombatLogParser, id: number) => { + const friendly = parser.report.friendlies.find((friendly) => friendly.id === id); + if (friendly) { + return friendly; + } + const enemy = parser.report.enemies.find((enemy) => enemy.id === id); + if (enemy) { + return enemy; + } + const enemyPet = parser.report.enemyPets.find((enemyPet) => enemyPet.id === id); + if (enemyPet) { + return enemyPet; + } + const pet = parser.playerPets.find((pet) => pet.id === id); + if (pet) { + return pet; + } + return null; +}; + +const getEventTypeName = (name: keyof typeof FILTERABLE_TYPES, isRawNames: boolean) => { + return isRawNames ? name : FILTERABLE_TYPES[name].name; +}; + +const getEventTypeExplanation = (name: keyof typeof FILTERABLE_TYPES) => { + const eventType = FILTERABLE_TYPES[name]; + return 'explanation' in eventType ? eventType.explanation : undefined; +}; + +const EntityCell = ({ entity }: { entity: PlayerInfo | PetInfo | EnemyInfo | null }) => { + if (!entity) { + return null; + } + return {entity.name}; +}; + +const AbilityCell = ({ ability }: { ability: Ability | null }) => { + if (!ability) { + return null; + } + const spellId = ability.guid; + + return ( + <> + + {ability.abilityIcon && } {ability.name} + {' '} + ID: {spellId} + + ); +}; + +const EventTabsToggle = ({ + checked, + id, + label, + onChange, + explanation, +}: { + checked: boolean; + id: string; + label: string; + onChange: () => void; + explanation?: string; +}) => { + return ( +
+ + {explanation && ( +
+ +
+ +
+
+
+ )} + +
+ ); +}; + +type Action = + | { type: 'turnAllOff' } + | { type: 'updateSearch'; search: string } + | { type: 'toggleShowFabricated' } + | { type: 'toggleRawNames' } + | { type: 'toggleEventType'; eventType: FilterableEventType }; + +interface State { + rawNames: boolean; + showFabricated: boolean; + search: string; + types: Record; +} + +const defaultState: State = { + rawNames: false, + showFabricated: false, + search: '', + types: allFiltersOn, +}; + +const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'turnAllOff': + return { ...state, types: allFiltersOff }; + case 'updateSearch': + return { ...state, search: action.search }; + case 'toggleShowFabricated': + return { ...state, showFabricated: !state.showFabricated }; + case 'toggleRawNames': + return { ...state, rawNames: !state.rawNames }; + case 'toggleEventType': + return { + ...state, + types: { ...state.types, [action.eventType]: !state.types[action.eventType] }, + }; + default: + return { ...state }; + } +}; + +interface EventsTabFnProps { + parser: CombatLogParser; +} +export default function EventsTabFn({ parser }: EventsTabFnProps) { + const [{ rawNames, showFabricated, search, types }, dispatch] = useReducer(reducer, defaultState); + + const regex = /"([^"]*)"|(\S+)/g; + const searchTerms = (search.match(regex) || []).map((m) => m.replace(regex, '$1$2')); + + const events = parser.eventHistory.filter((event) => { + if (!isFilterableEventType(event.type) || types[event.type] === false) { + return false; + } + if (!showFabricated && event.__fabricated === true) { + return false; + } + + // Search Logic + if (searchTerms.length === 0) { + return true; + } + + const source = HasSource(event) ? findEntity(parser, event.sourceID) : null; + const target = HasTarget(event) ? findEntity(parser, event.targetID) : null; + + return searchTerms.some((searchTerm) => { + if (HasAbility(event)) { + if (String(event.ability.guid) === searchTerm) { + return true; + } else if (event.ability.name && event.ability.name.toLowerCase().includes(searchTerm)) { + return true; + } + } + if (source !== null && source.name.toLowerCase().includes(searchTerm)) { + return true; + } + if (target !== null && target.name.toLowerCase().includes(searchTerm)) { + return true; + } + if (event.type !== null && event.type.toLowerCase().includes(searchTerm)) { + return true; + } + return false; + }); + }); + + return ( +
+
+

Events

+ This only includes events involving the selected player. +
+
+
+ + dispatch({ type: 'updateSearch', search: event.target.value.trim().toLowerCase() }) + } + placeholder="Search events" + autoCorrect="off" + autoCapitalize="off" + spellCheck="false" + /> +
+ {filterableEventTypes.filter(isFilterableEventType).map((type) => ( + dispatch({ type: 'toggleEventType', eventType: type })} + /> + ))} +
+
+ +
+
+ dispatch({ type: 'toggleShowFabricated' })} + /> + dispatch({ type: 'toggleRawNames' })} + /> +
+
+ Events with an orange background were{' '} + + modified + + . +
+
+ Events with a blue background were{' '} + + reordered + + . +
+
+
+ + {({ width }) => ( + events[index]} + rowHeight={25} + rowRenderer={(props) => + defaultTableRowRenderer({ + ...props, + className: `${props.className} ${props.rowData.__modified ? 'modified' : ''} ${ + props.rowData.__fabricated ? 'fabricated' : '' + } ${props.rowData.__reordered ? 'reordered' : ''}`, + }) + } + onRowClick={({ rowData }) => console.log(rowData)} + width={width} + > + + formatDuration(cellData - parser.fight.start_time + parser.fight.offset_time, 3) + } + disableSort + width={25} + flexGrow={1} + /> + ( +
{getEventTypeName(cellData, rawNames)}
+ )} + disableSort + width={50} + flexGrow={1} + /> + ( + + )} + disableSort + width={50} + flexGrow={1} + /> + ( + + )} + disableSort + width={50} + flexGrow={1} + /> + } + disableSort + width={160} + flexGrow={1} + /> + { + if (rowData.type === EventType.Damage) { + return ( + <> + + {formatThousands(rowData.amount)} + {' '} + {rowData.absorbed ? ( + A: {formatThousands(rowData.absorbed)} + ) : null}{' '} + Damage + + ); + } + if (rowData.type === EventType.Heal) { + return ( + <> + + {formatThousands(rowData.amount)} + {' '} + {rowData.absorbed ? ( + A: {formatThousands(rowData.absorbed)} + ) : null}{' '} + Healing + + ); + } + if (rowData.type === EventType.Absorbed) { + return ( + <> + {formatThousands(rowData.amount)}{' '} + Absorbed + + ); + } + if (rowData.type === EventType.ApplyBuff && rowData.absorb !== undefined) { + return ( + <> + Applied an absorb of{' '} + {formatThousands(rowData.absorb)}{' '} + Absorbed + + ); + } + if ( + rowData.type === EventType.ResourceChange || + rowData.type === EventType.Drain + ) { + const resource = RESOURCE_TYPES[rowData.resourceChangeType]; + const change = rowData.resourceChange - (rowData.waste || 0); + if (resource) { + return ( + <> + + {formatThousands(change)} {resource.name} + {' '} + {resource.icon && } + + ); + } + } + if ( + rowData.type === EventType.ApplyBuffStack || + rowData.type === EventType.ApplyDebuffStack || + rowData.type === EventType.RemoveBuffStack || + rowData.type === EventType.RemoveDebuffStack + ) { + const remove = + rowData.type === EventType.RemoveBuffStack || + rowData.type === EventType.RemoveDebuffStack; + return ( + <> + + {`\u2794 ${rowData.stack} stack${rowData.stack === 1 ? '' : 's'}`} + + + ); + } + return null; + }} + disableSort + width={60} + flexGrow={1} + /> + { + if (rowData.type === EventType.Damage) { + return ( + + {rowData.blocked ? ( + B: {formatThousands(rowData.blocked)} + ) : null} + + ); + } + if (rowData.type === EventType.Heal) { + return ( + + {rowData.overheal ? ( + O: {formatThousands(rowData.overheal)} + ) : null} + + ); + } + if (rowData.type === EventType.ResourceChange) { + const resource = RESOURCE_TYPES[rowData.resourceChangeType]; + if (resource) { + return ( + <> + + {rowData.waste > 0 ? `${formatThousands(rowData.waste)} wasted` : ''} + + + ); + } + } + return null; + }} + disableSort + width={25} + flexGrow={1} + /> +
+ )} +
+
+
+
+ ); +} From c74cb12db36e1526fcaf0df29d5ff2c62bdd5dc1 Mon Sep 17 00:00:00 2001 From: Vollmer Date: Thu, 28 Mar 2024 22:36:47 +0100 Subject: [PATCH 2/2] [Devastation] Update APL check module and some timeline cleanup (#6678) * Update and rework Devastation AplCheck * Add living flame to EssenceBurstNormalizer * Cleanup buffs * Add Nymue to Channeling normalizer * split living flame filler up * adding typing * simplify talentCheck and add some comments * more typing * changelogs * add missing comment * rename spells to rules for better consistency * rename AplCheck file so its more consistent with other analyzers * Update hasResource cnd to allow for setting initial resources * Update debuffMissing cnd to allow for custom fallback when no target is found * move standardEmpowerConditional to conditions file * add range to spells to fix inconsistencies with APL range check * Implement buffSoonPresent apl condition * Update apl rules * add buffSoonPresent to index.tsx * change magic number range to const surely we get more range soon copium * update buffSoonPresent key * Fix duplicate spell entry and add proper damage id for Nymue * Add PrePullCooldowns dep to Channeling normalizer * Implement prepull normalizer for living flame to handle specific scenario * changelog * add maximumLinks to BURNOUT_CONSUME castLink * Make overcap description generic * add some comments to the rules * use isFromBurnout * Fix APLCheck skipping chain casted abilities * Reverting some of the timeline changes to reduce scope of PR - moving to another branch * changelog * update TIERS enum in Buffs to match new format * add suggested docblock for buffSoonPresent --- src/CHANGELOG.tsx | 1 + .../retail/evoker/devastation/CHANGELOG.tsx | 1 + .../evoker/devastation/CombatLogParser.ts | 2 +- .../retail/evoker/devastation/constants.tsx | 2 + .../evoker/devastation/modules/Abilities.tsx | 5 + .../devastation/modules/AplCheck/AplCheck.tsx | 106 +++++ .../modules/AplCheck/conditions.tsx | 39 ++ .../devastation/modules/AplCheck/index.tsx | 412 ------------------ .../devastation/modules/AplCheck/rules.tsx | 341 +++++++++++++++ .../evoker/devastation/modules/Buffs.tsx | 45 +- .../modules/guide/CoreRotation.tsx | 6 +- .../modules/normalizers/CastLinkNormalizer.ts | 1 + .../normalizers/EssenceBurstNormalizer.ts | 6 +- .../retail/evoker/shared/constants.ts | 2 + .../evoker/shared/modules/Abilities.tsx | 7 + .../apl/conditions/buffSoonPresent.tsx | 87 ++++ .../metrics/apl/conditions/debuffMissing.tsx | 5 +- .../metrics/apl/conditions/hasResource.tsx | 9 +- .../shared/metrics/apl/conditions/index.tsx | 1 + src/parser/shared/metrics/apl/index.ts | 23 +- 20 files changed, 664 insertions(+), 437 deletions(-) create mode 100644 src/analysis/retail/evoker/devastation/modules/AplCheck/AplCheck.tsx create mode 100644 src/analysis/retail/evoker/devastation/modules/AplCheck/conditions.tsx delete mode 100644 src/analysis/retail/evoker/devastation/modules/AplCheck/index.tsx create mode 100644 src/analysis/retail/evoker/devastation/modules/AplCheck/rules.tsx create mode 100644 src/parser/shared/metrics/apl/conditions/buffSoonPresent.tsx diff --git a/src/CHANGELOG.tsx b/src/CHANGELOG.tsx index 245a046ab9f..e52207618a2 100644 --- a/src/CHANGELOG.tsx +++ b/src/CHANGELOG.tsx @@ -40,6 +40,7 @@ export default [ change(date(2024, 3, 26), 'Remove support for Shadowlands tier sets.', ToppleTheNun), change(date(2024, 3, 26), 'Add tier set IDs for Dragonflight season 4.', ToppleTheNun), change(date(2024, 3, 22), 'Update Channeling normalizer to attach fabricated channel events to their associated cast events.', Vollmer), + change(date(2024, 3, 17), <>Implement buffSoonPresent APL condition and fix chain cast issues with APL check., Vollmer), change(date(2024, 3, 14), 'Correct getBuffStacks method to return the stacks at the given timestamp', Earosselot), change(date(2024, 3, 14), 'Fix overflow on cooldown bars while using the phase selector.', ToppleTheNun), change(date(2024, 3, 14), 'Bump opacity on phase selector to 75% from 40%.', ToppleTheNun), diff --git a/src/analysis/retail/evoker/devastation/CHANGELOG.tsx b/src/analysis/retail/evoker/devastation/CHANGELOG.tsx index ef41c80c1ab..b1330c9ddc7 100644 --- a/src/analysis/retail/evoker/devastation/CHANGELOG.tsx +++ b/src/analysis/retail/evoker/devastation/CHANGELOG.tsx @@ -6,6 +6,7 @@ import SPELLS from 'common/SPELLS/evoker'; import RESOURCE_TYPES from 'game/RESOURCE_TYPES'; export default [ + change(date(2024, 3, 17), 'Update APL Check and timeline cleanup.', Vollmer), change(date(2024, 3, 7), <>Fix an issue with module when no was active during the fight., Vollmer), change(date(2024, 3, 6), <>Make it more apparent that you can mouseover points in the graph., Vollmer), change(date(2024, 2, 10), <>Fix crash in module., Trevor), diff --git a/src/analysis/retail/evoker/devastation/CombatLogParser.ts b/src/analysis/retail/evoker/devastation/CombatLogParser.ts index 7eee26f5df0..a14ac9e1c29 100644 --- a/src/analysis/retail/evoker/devastation/CombatLogParser.ts +++ b/src/analysis/retail/evoker/devastation/CombatLogParser.ts @@ -5,7 +5,7 @@ import Abilities from './modules/Abilities'; import ShatteringStar from './modules/abilities/ShatteringStar'; import Buffs from './modules/Buffs'; import Guide from './Guide'; -import AplCheck from './modules/AplCheck'; +import AplCheck from './modules/AplCheck/AplCheck'; import Disintegrate from './modules/abilities/Disintegrate'; import EssenceBurst from './modules/abilities/EssenceBurst'; import Burnout from './modules/abilities/Burnout'; diff --git a/src/analysis/retail/evoker/devastation/constants.tsx b/src/analysis/retail/evoker/devastation/constants.tsx index 3be125fe9e0..524b90ee988 100644 --- a/src/analysis/retail/evoker/devastation/constants.tsx +++ b/src/analysis/retail/evoker/devastation/constants.tsx @@ -32,3 +32,5 @@ export const DEVA_T31_2PC_MULTIPLER = 0.05; export const POWER_SWELL_REGEN_FACTOR = 1; export const DENSE_ENERGY_ESSENCE_REDUCTION = 1; + +export const OPTIMAL_EMPOWER_DRAGONRAGE_GAP_ST_MS = 13000; diff --git a/src/analysis/retail/evoker/devastation/modules/Abilities.tsx b/src/analysis/retail/evoker/devastation/modules/Abilities.tsx index 744d9f1149c..7d2ef47a39f 100644 --- a/src/analysis/retail/evoker/devastation/modules/Abilities.tsx +++ b/src/analysis/retail/evoker/devastation/modules/Abilities.tsx @@ -3,6 +3,7 @@ import CoreAbilities from 'analysis/retail/evoker/shared/modules/Abilities'; import { SpellbookAbility } from 'parser/core/modules/Ability'; import SPELL_CATEGORY from 'parser/core/SPELL_CATEGORY'; import SPELLS from 'common/SPELLS'; +import { BASE_EVOKER_RANGE } from '../../shared'; class Abilities extends CoreAbilities { spellbook(): SpellbookAbility[] { @@ -15,6 +16,7 @@ class Abilities extends CoreAbilities { gcd: { base: 1500, }, + range: BASE_EVOKER_RANGE, enabled: combatant.hasTalent(TALENTS.PYRE_TALENT), }, { @@ -28,6 +30,7 @@ class Abilities extends CoreAbilities { suggestion: true, recommendedEfficiency: 0.9, }, + range: BASE_EVOKER_RANGE, enabled: combatant.hasTalent(TALENTS.FIRESTORM_TALENT), }, { @@ -43,6 +46,7 @@ class Abilities extends CoreAbilities { suggestion: true, recommendedEfficiency: 0.95, }, + range: BASE_EVOKER_RANGE, enabled: combatant.hasTalent(TALENTS.ETERNITY_SURGE_TALENT), }, { @@ -57,6 +61,7 @@ class Abilities extends CoreAbilities { recommendedEfficiency: 0.9, extraSuggestion: 'You should aim to use this off CD.', }, + range: BASE_EVOKER_RANGE, enabled: combatant.hasTalent(TALENTS.SHATTERING_STAR_TALENT), }, //endregion diff --git a/src/analysis/retail/evoker/devastation/modules/AplCheck/AplCheck.tsx b/src/analysis/retail/evoker/devastation/modules/AplCheck/AplCheck.tsx new file mode 100644 index 00000000000..05fa520b5c2 --- /dev/null +++ b/src/analysis/retail/evoker/devastation/modules/AplCheck/AplCheck.tsx @@ -0,0 +1,106 @@ +import SPELLS from 'common/SPELLS/evoker'; +import { suggestion } from 'parser/core/Analyzer'; +import aplCheck, { Apl, build, CheckResult, PlayerInfo, Rule } from 'parser/shared/metrics/apl'; +import annotateTimeline from 'parser/shared/metrics/apl/annotate'; +import TALENTS from 'common/TALENTS/evoker'; +import { AnyEvent } from 'parser/core/Events'; +import Spell from 'common/SPELLS/Spell'; +import { getRules, Rules } from './rules'; + +export type TalentInfo = { + maxEssenceBurst: number; + maxEssence: number; + eternitySurgeSpell: Spell[]; + fireBreathSpell: Spell[]; + hasEventHorizon: boolean; + hasIridescence: boolean; + hasProtractedTalons: boolean; +}; + +const default_rotation = (rules: Rules): Rule[] => { + return [ + /** Top priority spells */ + rules.snapFireFirestorm, + rules.ehEternitySurge, + rules.fireBreath, + rules.aoeEternitySurge, + rules.shatteringStar, + rules.stEternitySurge, + rules.aoeFirestorm, + rules.aoeLivingFlame, + rules.stBurnoutLivingFlame, + + /** Spenders */ + rules.aoePyre, + rules.threeTargetPyre, + rules.disintegrate, + + /** Fillers */ + rules.stFirestorm, + rules.aoeAzureStrike, + rules.greenSpells, + rules.dragonRageFillerLivingFlame, + rules.fillerLivingFlame, + SPELLS.AZURE_STRIKE, + ]; +}; + +const talentCheck = (info: PlayerInfo): TalentInfo => { + const talentInfo: TalentInfo = { + maxEssenceBurst: 1, + maxEssence: 5, + /** The reason for defining only one version of our empower spell + * is that if we include both font and non font version it will show up as + * "Cast Fire Breath or Fire Breath...", since it then assumes we have both available. + * This looks a bit weird so we try to define the version that is actively talented. */ + eternitySurgeSpell: [SPELLS.ETERNITY_SURGE], + fireBreathSpell: [SPELLS.FIRE_BREATH], + /** Below talents have rotational changes */ + hasEventHorizon: false, + hasIridescence: false, + hasProtractedTalons: false, + }; + if (!info || !info?.combatant) { + /** If we don't know whether the player has font talented or not + * we need to make sure we have both included */ + talentInfo.fireBreathSpell = [SPELLS.FIRE_BREATH, SPELLS.FIRE_BREATH_FONT]; + talentInfo.eternitySurgeSpell = [SPELLS.ETERNITY_SURGE, SPELLS.ETERNITY_SURGE_FONT]; + return talentInfo; + } + + const combatant = info.combatant; + + talentInfo.maxEssenceBurst = combatant.hasTalent(TALENTS.ESSENCE_ATTUNEMENT_TALENT) ? 2 : 1; + talentInfo.maxEssence = combatant.hasTalent(TALENTS.POWER_NEXUS_TALENT) ? 6 : 5; + + if (combatant.hasTalent(TALENTS.FONT_OF_MAGIC_DEVASTATION_TALENT)) { + talentInfo.fireBreathSpell = [SPELLS.FIRE_BREATH_FONT]; + talentInfo.eternitySurgeSpell = [SPELLS.ETERNITY_SURGE_FONT]; + } + + talentInfo.hasEventHorizon = combatant.hasTalent(TALENTS.EVENT_HORIZON_TALENT); + talentInfo.hasIridescence = combatant.hasTalent(TALENTS.IRIDESCENCE_TALENT); + talentInfo.hasProtractedTalons = combatant.hasTalent(TALENTS.PROTRACTED_TALONS_TALENT); + + return talentInfo; +}; + +export const apl = (info: PlayerInfo): Apl => { + const talentInfo = talentCheck(info); + + const rules: Rules = getRules(talentInfo); + + return build(default_rotation(rules)); +}; + +export const check = (events: AnyEvent[], info: PlayerInfo): CheckResult => { + const check = aplCheck(apl(info)); + return check(events, info); +}; + +export default suggestion((events, info) => { + const { violations } = check(events, info); + annotateTimeline(violations); + + return undefined; +}); diff --git a/src/analysis/retail/evoker/devastation/modules/AplCheck/conditions.tsx b/src/analysis/retail/evoker/devastation/modules/AplCheck/conditions.tsx new file mode 100644 index 00000000000..9db161700ed --- /dev/null +++ b/src/analysis/retail/evoker/devastation/modules/AplCheck/conditions.tsx @@ -0,0 +1,39 @@ +import TALENTS from 'common/TALENTS/evoker'; +import RESOURCE_TYPES from 'game/RESOURCE_TYPES'; +import * as cnd from 'parser/shared/metrics/apl/conditions'; +import SPELLS from 'common/SPELLS/evoker'; +import { SpellLink } from 'interface'; +import { tenseAlt } from 'parser/shared/metrics/apl'; +import { OPTIMAL_EMPOWER_DRAGONRAGE_GAP_ST_MS } from '../../constants'; + +export const avoidIfDragonRageSoon = (time: number = OPTIMAL_EMPOWER_DRAGONRAGE_GAP_ST_MS) => { + return cnd.describe( + cnd.buffSoonPresent(TALENTS.DRAGONRAGE_TALENT, { + atLeast: time, + }), + (tense) => ( + <> + there {tenseAlt(tense, <>is, <>was)} atleast {time / 1000} seconds left before{' '} + {tenseAlt(tense, <>using, <>you used)} + + ), + ); +}; + +export const hasEssenceRequirement = (resources: number, initial: number) => { + return cnd.always( + cnd.or( + cnd.hasResource(RESOURCE_TYPES.ESSENCE, { atLeast: resources }, initial), + cnd.buffPresent(SPELLS.ESSENCE_BURST_DEV_BUFF), + ), + ); +}; + +export const standardEmpowerConditional = cnd.or( + cnd.describe(cnd.buffPresent(TALENTS.DRAGONRAGE_TALENT), (tense) => ( + <> + {tenseAlt(tense, <>in, <>you were in)} + + )), + avoidIfDragonRageSoon(), +); diff --git a/src/analysis/retail/evoker/devastation/modules/AplCheck/index.tsx b/src/analysis/retail/evoker/devastation/modules/AplCheck/index.tsx deleted file mode 100644 index 37e7a755830..00000000000 --- a/src/analysis/retail/evoker/devastation/modules/AplCheck/index.tsx +++ /dev/null @@ -1,412 +0,0 @@ -import SPELLS from 'common/SPELLS'; -import { suggestion } from 'parser/core/Analyzer'; -import aplCheck, { Apl, build, CheckResult, PlayerInfo, Rule } from 'parser/shared/metrics/apl'; -import annotateTimeline from 'parser/shared/metrics/apl/annotate'; -import TALENTS from 'common/TALENTS/evoker'; -import * as cnd from 'parser/shared/metrics/apl/conditions'; - -import { AnyEvent, EventType } from 'parser/core/Events'; -import RESOURCE_TYPES from 'game/RESOURCE_TYPES'; -import { SpellLink } from 'interface'; - -const avoidIfDragonRageSoon = (time: number) => { - return cnd.spellCooldownRemaining(TALENTS.DRAGONRAGE_TALENT, { atLeast: time }); -}; - -const hasEssenceRequirement = (resources: number) => { - return cnd.or( - cnd.hasResource(RESOURCE_TYPES.ESSENCE, { atLeast: resources }), - cnd.buffPresent(SPELLS.ESSENCE_BURST_DEV_BUFF), - ); -}; - -const COMMON_TOP: Rule[] = [ - // Spend Snapfire procs ASAP - { - spell: TALENTS.FIRESTORM_TALENT, - condition: cnd.buffPresent(SPELLS.SNAPFIRE_BUFF), - }, - - // With Event Horizon talented, use Eternity Surge before Fire Breath during DR - { - spell: SPELLS.ETERNITY_SURGE, - condition: cnd.and( - cnd.hasTalent(TALENTS.EVENT_HORIZON_TALENT), - cnd.buffPresent(TALENTS.DRAGONRAGE_TALENT), - ), - }, - { - spell: SPELLS.ETERNITY_SURGE_FONT, - condition: cnd.and( - cnd.hasTalent(TALENTS.EVENT_HORIZON_TALENT), - cnd.buffPresent(TALENTS.DRAGONRAGE_TALENT), - ), - }, - - // Use empower inside DR - // Make sure not to overlap T30 4pc buff - // Make sure to not use empower too close to dragonrage - { - spell: SPELLS.FIRE_BREATH, - condition: cnd.or( - cnd.buffPresent(TALENTS.DRAGONRAGE_TALENT), - cnd.and(cnd.buffMissing(SPELLS.POWER_SWELL_BUFF), avoidIfDragonRageSoon(13000)), - ), - }, - { - spell: SPELLS.FIRE_BREATH_FONT, - condition: cnd.or( - cnd.buffPresent(TALENTS.DRAGONRAGE_TALENT), - cnd.and(cnd.buffMissing(SPELLS.POWER_SWELL_BUFF), avoidIfDragonRageSoon(13000)), - ), - }, - // Use ES over star in AoE - { - spell: SPELLS.ETERNITY_SURGE, - condition: cnd.and( - cnd.targetsHit( - { atLeast: 3 }, - { lookahead: 3000, targetType: EventType.Damage, targetSpell: SPELLS.ETERNITY_SURGE_DAM }, - ), - cnd.or( - cnd.buffPresent(TALENTS.DRAGONRAGE_TALENT), - cnd.and(cnd.buffMissing(SPELLS.POWER_SWELL_BUFF), avoidIfDragonRageSoon(13000)), - ), - ), - }, - { - spell: SPELLS.ETERNITY_SURGE_FONT, - condition: cnd.and( - cnd.targetsHit( - { atLeast: 3 }, - { lookahead: 3000, targetType: EventType.Damage, targetSpell: SPELLS.ETERNITY_SURGE_DAM }, - ), - cnd.or( - cnd.buffPresent(TALENTS.DRAGONRAGE_TALENT), - cnd.and(cnd.buffMissing(SPELLS.POWER_SWELL_BUFF), avoidIfDragonRageSoon(13000)), - ), - ), - }, - { - spell: SPELLS.DEEP_BREATH, - condition: cnd.describe( - cnd.optionalRule( - cnd.and( - cnd.buffMissing(TALENTS.DRAGONRAGE_TALENT), - cnd.targetsHit( - { atLeast: 3 }, - { lookahead: 3000, targetType: EventType.Damage, targetSpell: SPELLS.DEEP_BREATH_DAM }, - ), - ), - ), - () => ( - <> - isn't present (AoE) - - ), - ), - }, - // Only use star if it doesn't overcap EB or you don't play vigor - { - spell: TALENTS.SHATTERING_STAR_TALENT, - condition: cnd.describe( - cnd.or( - cnd.and( - cnd.hasTalent(TALENTS.ARCANE_VIGOR_TALENT), - cnd.or( - cnd.and( - cnd.hasTalent(TALENTS.ESSENCE_ATTUNEMENT_TALENT), - cnd.buffStacks(SPELLS.ESSENCE_BURST_DEV_BUFF, { atMost: 1 }), - ), - cnd.and( - cnd.not(cnd.hasTalent(TALENTS.ESSENCE_ATTUNEMENT_TALENT)), - cnd.buffStacks(SPELLS.ESSENCE_BURST_DEV_BUFF, { atMost: 0 }), - ), - ), - ), - cnd.not(cnd.hasTalent(TALENTS.ARCANE_VIGOR_TALENT)), - ), - () => ( - <> - you won't overcap on or if you don't - play - - ), - ), - }, - // Use empower inside DR - // Make sure not to overlap T30 4pc buff - // Make sure to not use empower too close to dragonrage - { - spell: SPELLS.ETERNITY_SURGE, - condition: cnd.or( - cnd.buffPresent(TALENTS.DRAGONRAGE_TALENT), - cnd.and(cnd.buffMissing(SPELLS.POWER_SWELL_BUFF), avoidIfDragonRageSoon(13000)), - ), - }, - { - spell: SPELLS.ETERNITY_SURGE_FONT, - condition: cnd.or( - cnd.buffPresent(TALENTS.DRAGONRAGE_TALENT), - cnd.and(cnd.buffMissing(SPELLS.POWER_SWELL_BUFF), avoidIfDragonRageSoon(13000)), - ), - }, - // Hard cast only Firestorm in AoE - { - spell: TALENTS.FIRESTORM_TALENT, - condition: cnd.targetsHit( - { atLeast: 3 }, - { lookahead: 2000, targetType: EventType.Damage, targetSpell: SPELLS.FIRESTORM_DAMAGE }, - ), - }, -]; - -export const COMMON_BOTTOM: Rule[] = [ - // Hard cast only Firestorm outside of SS and DR windows - { - spell: TALENTS.FIRESTORM_TALENT, - condition: cnd.and( - cnd.buffMissing(TALENTS.DRAGONRAGE_TALENT), - cnd.debuffMissing(SPELLS.SHATTERING_STAR), - ), - }, - { - spell: SPELLS.EMERALD_BLOSSOM_CAST, - condition: cnd.describe( - cnd.and( - cnd.buffMissing(TALENTS.DRAGONRAGE_TALENT), - cnd.hasTalent(TALENTS.ANCIENT_FLAME_TALENT), - cnd.buffMissing(SPELLS.ANCIENT_FLAME_BUFF), - cnd.hasTalent(TALENTS.SCARLET_ADAPTATION_TALENT), - ), - () => ( - <> - {' '} - you are talented into and{' '} - , and you don't have either{' '} - or{' '} - buffs currently up - - ), - ), - }, - - { - spell: SPELLS.VERDANT_EMBRACE_HEAL, - condition: cnd.describe( - cnd.and( - cnd.buffMissing(TALENTS.DRAGONRAGE_TALENT), - cnd.hasTalent(TALENTS.ANCIENT_FLAME_TALENT), - cnd.buffMissing(SPELLS.ANCIENT_FLAME_BUFF), - cnd.hasTalent(TALENTS.SCARLET_ADAPTATION_TALENT), - ), - () => ( - <> - {' '} - you are talented into and{' '} - , and you don't have either{' '} - or{' '} - buffs currently up - - ), - ), - }, - - { - spell: SPELLS.AZURE_STRIKE, - condition: cnd.targetsHit( - { atLeast: 2 }, - { lookahead: 1000, targetType: EventType.Damage, targetSpell: SPELLS.AZURE_STRIKE }, - ), - }, - - { - spell: SPELLS.LIVING_FLAME_CAST, - condition: cnd.describe( - cnd.and( - cnd.buffPresent(TALENTS.DRAGONRAGE_TALENT), - cnd.or(cnd.buffPresent(SPELLS.IRIDESCENCE_RED), cnd.buffPresent(SPELLS.IRIDESCENCE_BLUE)), - ), - () => ( - <> - is present and{' '} - or{' '} - is present. - - ), - ), - }, - { - spell: SPELLS.LIVING_FLAME_CAST, - condition: cnd.buffMissing(TALENTS.DRAGONRAGE_TALENT), - }, - - SPELLS.AZURE_STRIKE, -]; - -const default_rotation = build([ - ...COMMON_TOP, - - // Chained disintegrate - Chaining takes prio over clipping - { - spell: SPELLS.DISINTEGRATE, - condition: cnd.and( - cnd.always(hasEssenceRequirement(3)), - cnd.lastSpellCast(SPELLS.DISINTEGRATE), - ), - }, - - // Leaping Flames with burnout - { - spell: SPELLS.LIVING_FLAME_CAST, - condition: cnd.and( - cnd.buffPresent(SPELLS.LEAPING_FLAMES_BUFF), - cnd.hasResource(RESOURCE_TYPES.ESSENCE, { atMost: 4 }), - cnd.buffMissing(SPELLS.ESSENCE_BURST_DEV_BUFF), - cnd.or( - cnd.targetsHit( - { atLeast: 4 }, - { - lookahead: 2000, - targetType: EventType.Damage, - targetSpell: SPELLS.LIVING_FLAME_DAMAGE, - }, - ), - cnd.buffPresent(SPELLS.BURNOUT_BUFF), - cnd.and( - cnd.buffPresent(TALENTS.SCARLET_ADAPTATION_TALENT), - cnd.targetsHit( - { atLeast: 3 }, - { - lookahead: 2000, - targetType: EventType.Damage, - targetSpell: SPELLS.LIVING_FLAME_DAMAGE, - }, - ), - ), - ), - ), - }, - // Leaping flames no burnout - { - spell: SPELLS.LIVING_FLAME_CAST, - condition: cnd.and( - cnd.buffPresent(SPELLS.LEAPING_FLAMES_BUFF), - cnd.hasResource(RESOURCE_TYPES.ESSENCE, { atMost: 4 }), - cnd.buffMissing(SPELLS.ESSENCE_BURST_DEV_BUFF), - cnd.hasTalent(TALENTS.BURNOUT_TALENT), - cnd.targetsHit( - { atLeast: 3 }, - { - lookahead: 2000, - targetType: EventType.Damage, - targetSpell: SPELLS.LIVING_FLAME_DAMAGE, - }, - ), - ), - }, - - // Burnout no leaping flames - { - spell: SPELLS.LIVING_FLAME_CAST, - condition: cnd.and( - cnd.buffPresent(SPELLS.BURNOUT_BUFF), - cnd.buffMissing(SPELLS.LEAPING_FLAMES_BUFF), - cnd.hasResource(RESOURCE_TYPES.ESSENCE, { atMost: 4 }), - cnd.buffStacks(SPELLS.ESSENCE_BURST_DEV_BUFF, { atMost: 1 }), - ), - }, - - // Pyre when atleast 4 targets are hit - { - spell: TALENTS.PYRE_TALENT, - condition: cnd.targetsHit( - { atLeast: 4 }, - { lookahead: 2000, targetType: EventType.Damage, targetSpell: SPELLS.PYRE }, - ), - }, - // Pyre when atleast 3 targets are hit with 15 stacks of CB - { - spell: TALENTS.PYRE_TALENT, - condition: cnd.and( - cnd.targetsHit( - { atLeast: 3 }, - { lookahead: 2000, targetType: EventType.Damage, targetSpell: SPELLS.PYRE }, - ), - cnd.buffStacks(SPELLS.CHARGED_BLAST, { atLeast: 15 }), - ), - }, - // Pyre when atleast 3 targets are hit and if talented into CB and neither EB or blue buff is up - { - spell: TALENTS.PYRE_TALENT, - condition: cnd.describe( - cnd.and( - cnd.targetsHit( - { atLeast: 3 }, - { lookahead: 2000, targetType: EventType.Damage, targetSpell: SPELLS.PYRE }, - ), - cnd.hasTalent(TALENTS.CHARGED_BLAST_TALENT), - cnd.or( - cnd.not(cnd.buffPresent(SPELLS.ESSENCE_BURST_DEV_BUFF)), - cnd.not(cnd.buffPresent(SPELLS.IRIDESCENCE_BLUE)), - ), - ), - () => ( - <> - it would hit at least 3 targets and is talented - and neither nor{' '} - is up - - ), - ), - }, - // Pyre when atleast 3 targets are hit if not playing CB aslong as both EB and blue buff isn't up at the same time. - { - spell: TALENTS.PYRE_TALENT, - condition: cnd.describe( - cnd.and( - cnd.targetsHit( - { atLeast: 3 }, - { lookahead: 2000, targetType: EventType.Damage, targetSpell: SPELLS.PYRE }, - ), - cnd.not(cnd.hasTalent(TALENTS.CHARGED_BLAST_TALENT)), - cnd.not( - cnd.and( - cnd.buffPresent(SPELLS.ESSENCE_BURST_DEV_BUFF), - cnd.buffPresent(SPELLS.IRIDESCENCE_BLUE), - ), - ), - ), - () => ( - <> - it would hit at least 3 targets and both{' '} - and{' '} - isn't up - - ), - ), - }, - - { - spell: SPELLS.DISINTEGRATE, - condition: cnd.always(hasEssenceRequirement(3)), - }, - - ...COMMON_BOTTOM, -]); - -export const apl = (): Apl => { - return default_rotation; -}; - -export const check = (events: AnyEvent[], info: PlayerInfo): CheckResult => { - const check = aplCheck(apl()); - return check(events, info); -}; - -export default suggestion((events, info) => { - const { violations } = check(events, info); - annotateTimeline(violations); - - return undefined; -}); diff --git a/src/analysis/retail/evoker/devastation/modules/AplCheck/rules.tsx b/src/analysis/retail/evoker/devastation/modules/AplCheck/rules.tsx new file mode 100644 index 00000000000..47ccbafc208 --- /dev/null +++ b/src/analysis/retail/evoker/devastation/modules/AplCheck/rules.tsx @@ -0,0 +1,341 @@ +import { TalentInfo } from './AplCheck'; +import SPELLS from 'common/SPELLS/evoker'; +import TALENTS from 'common/TALENTS/evoker'; +import RESOURCE_TYPES from 'game/RESOURCE_TYPES'; +import { ResourceLink, SpellLink } from 'interface'; +import { EventType } from 'parser/core/Events'; +import { Rule, Tense, tenseAlt } from 'parser/shared/metrics/apl'; +import * as cnd from 'parser/shared/metrics/apl/conditions'; +import { + avoidIfDragonRageSoon, + hasEssenceRequirement, + standardEmpowerConditional, +} from './conditions'; + +export type Rules = { + [K in keyof ReturnType]: Rule; +}; + +const noOvercapDescription = ( + tense: Tense | undefined, + includeEssence: boolean = true, +): JSX.Element => { + return ( + <> + you {tenseAlt(tense, <>won't, <>wouldn't)} overcap{' '} + {includeEssence && ( + <> + or + + )} + + + ); +}; + +export const getRules = (info: TalentInfo) => { + return { + shatteringStar: shatteringStar(info), + snapFireFirestorm, + aoeFirestorm, + stFirestorm, + fireBreath: fireBreath(info), + stEternitySurge: stEternitySurge(info), + ehEternitySurge: ehEternitySurge(info), + aoeEternitySurge: aoeEternitySurge(info), + aoeLivingFlame: aoeLivingFlame(info), + stBurnoutLivingFlame: stBurnoutLivingFlame(info), + dragonRageFillerLivingFlame, + fillerLivingFlame, + greenSpells, + aoePyre: aoePyre(info), + threeTargetPyre: threeTargetPyre(info), + disintegrate: disintegrate(info), + aoeAzureStrike: aoeAzureStrike(info), + }; +}; + +const shatteringStar = (info: TalentInfo): Rule => { + const baseCondition = cnd.describe( + cnd.or( + cnd.and( + cnd.hasTalent(TALENTS.ARCANE_VIGOR_TALENT), + cnd.buffStacks(SPELLS.ESSENCE_BURST_DEV_BUFF, { + atMost: info.maxEssenceBurst - 1, + }), + ), + cnd.not(cnd.hasTalent(TALENTS.ARCANE_VIGOR_TALENT)), + ), + (tense) => <>{noOvercapDescription(tense, false)}, + ); + + const ssRule: Rule = { + spell: TALENTS.SHATTERING_STAR_TALENT, + condition: + !info.hasEventHorizon && info.hasIridescence + ? cnd.and(baseCondition, cnd.buffMissing(SPELLS.IRIDESCENCE_BLUE)) + : baseCondition, + }; + return ssRule; +}; +const snapFireFirestorm: Rule = { + spell: TALENTS.FIRESTORM_TALENT, + condition: cnd.buffPresent(SPELLS.SNAPFIRE_BUFF), +}; +const aoeFirestorm: Rule = { + spell: TALENTS.FIRESTORM_TALENT, + condition: cnd.targetsHit( + { atLeast: 3 }, + { + lookahead: 2000, + targetType: EventType.Damage, + targetSpell: SPELLS.FIRESTORM_DAMAGE, + }, + ), +}; +const stFirestorm: Rule = { + spell: TALENTS.FIRESTORM_TALENT, + condition: cnd.and( + cnd.buffMissing(TALENTS.DRAGONRAGE_TALENT), + cnd.debuffMissing(TALENTS.SHATTERING_STAR_TALENT, undefined, undefined, true), + ), +}; +const fireBreath = (info: TalentInfo): Rule => { + return { + spell: info.fireBreathSpell, + condition: standardEmpowerConditional, + }; +}; +const stEternitySurge = (info: TalentInfo): Rule => { + return { + spell: info.eternitySurgeSpell, + condition: info.hasEventHorizon ? avoidIfDragonRageSoon() : standardEmpowerConditional, + }; +}; +const ehEternitySurge = (info: TalentInfo): Rule => { + return { + spell: info.eternitySurgeSpell, + condition: cnd.describe( + cnd.and( + cnd.buffPresent(TALENTS.DRAGONRAGE_TALENT), + cnd.hasTalent(TALENTS.EVENT_HORIZON_TALENT), + ), + (tense) => ( + <> + {tenseAlt(tense, <>in, <>you were in)}{' '} + and you{' '} + {tenseAlt(tense, <>have, <>had)} {' '} + talented + + ), + ), + }; +}; +/** There are some specific rules for downranking which would only hit + * 1/2 targets, but this will work just fine for the average Deva */ +const aoeEternitySurge = (info: TalentInfo): Rule => { + return { + spell: info.eternitySurgeSpell, + condition: cnd.and( + cnd.targetsHit( + { atLeast: 3 }, + { lookahead: 3000, targetType: EventType.Damage, targetSpell: SPELLS.ETERNITY_SURGE_DAM }, + ), + standardEmpowerConditional, + ), + }; +}; +/** This looks a bit advanced, but basically it's just a check for whether or not you overcap resources + * it could be split up to 3/4 target rules, but for general play the difference is minor enough + * that this generic rule covers the AoE usage just fine */ +const aoeLivingFlame = (info: TalentInfo): Rule => { + return { + spell: SPELLS.LIVING_FLAME_CAST, + condition: cnd.describe( + cnd.and( + cnd.buffPresent(SPELLS.LEAPING_FLAMES_BUFF), + cnd.hasResource(RESOURCE_TYPES.ESSENCE, { atMost: info.maxEssence - 1 }), + cnd.buffMissing(SPELLS.ESSENCE_BURST_DEV_BUFF), + cnd.or( + cnd.targetsHit( + { atLeast: 4 }, + { + lookahead: 2000, + targetType: EventType.Damage, + targetSpell: SPELLS.LIVING_FLAME_DAMAGE, + }, + ), + cnd.and( + cnd.or( + cnd.buffPresent(SPELLS.BURNOUT_BUFF), + cnd.not(cnd.hasTalent(TALENTS.BURNOUT_TALENT)), + cnd.buffPresent(TALENTS.SCARLET_ADAPTATION_TALENT), + ), + cnd.targetsHit( + { atLeast: 3 }, + { + lookahead: 2000, + targetType: EventType.Damage, + targetSpell: SPELLS.LIVING_FLAME_DAMAGE, + }, + ), + ), + ), + ), + (tense) => ( + <> + you {tenseAlt(tense, <>have, <>had)}{' '} + and {noOvercapDescription(tense)}{' '} + (AoE) + + ), + ), + }; +}; +/** This is also just a resource overcap check */ +const stBurnoutLivingFlame = (info: TalentInfo): Rule => { + return { + spell: SPELLS.LIVING_FLAME_CAST, + condition: cnd.describe( + cnd.optionalRule( + cnd.and( + cnd.buffPresent(SPELLS.BURNOUT_BUFF), + cnd.hasResource(RESOURCE_TYPES.ESSENCE, { atMost: info.maxEssence - 2 }), + cnd.or( + cnd.and( + cnd.buffStacks(SPELLS.ESSENCE_BURST_DEV_BUFF, { atMost: info.maxEssence - 1 }), + cnd.buffMissing(SPELLS.LEAPING_FLAMES_BUFF), + ), + cnd.and( + cnd.buffMissing(SPELLS.ESSENCE_BURST_DEV_BUFF), + cnd.buffPresent(SPELLS.LEAPING_FLAMES_BUFF), + ), + ), + ), + ), + (tense) => ( + <> + you {tenseAlt(tense, <>have, <>had)} and{' '} + {noOvercapDescription(tense)} + + ), + ), + }; +}; +/** Living Flame filler inside of Dragonrage */ +const dragonRageFillerLivingFlame: Rule = { + spell: SPELLS.LIVING_FLAME_CAST, + condition: cnd.describe( + cnd.or(cnd.buffPresent(SPELLS.IRIDESCENCE_RED), cnd.buffPresent(SPELLS.IRIDESCENCE_BLUE)), + (tense) => ( + <> + either or{' '} + {tenseAlt(tense, <>is, <>was)} present + + ), + ), +}; +/** Living Flame filler outside of Dragonrage */ +const fillerLivingFlame: Rule = { + spell: SPELLS.LIVING_FLAME_CAST, + condition: cnd.buffMissing(TALENTS.DRAGONRAGE_TALENT), +}; +/** Emerald Blossom & Verdant Embrace can be rotational buttons for Devastation */ +const greenSpells: Rule = { + spell: [SPELLS.EMERALD_BLOSSOM_CAST, TALENTS.VERDANT_EMBRACE_TALENT], + condition: cnd.describe( + cnd.and( + cnd.buffMissing(TALENTS.DRAGONRAGE_TALENT), + cnd.hasTalent(TALENTS.ANCIENT_FLAME_TALENT), + cnd.buffMissing(SPELLS.ANCIENT_FLAME_BUFF), + cnd.hasTalent(TALENTS.SCARLET_ADAPTATION_TALENT), + cnd.debuffMissing(TALENTS.SHATTERING_STAR_TALENT, undefined, undefined, true), + ), + (tense) => ( + <> + {tenseAlt(tense, <>is, <>was)} missing + + ), + ), +}; +/** General spender info: + * We check for whether the player has resources available to cast the spenders, + * but it's bloat to show the resource req for the checklist rule, so we'll only + * show it when looking at violations. + * It should be very obvious that you need resources to use spenders :) */ +/** We split up Pyre since there are big gains in managing your spenders properly */ +const aoePyre = (info: TalentInfo): Rule => { + return { + spell: TALENTS.PYRE_TALENT, + condition: cnd.describe( + cnd.and( + cnd.targetsHit( + { atLeast: 4 }, + { lookahead: 2000, targetType: EventType.Damage, targetSpell: SPELLS.PYRE }, + ), + hasEssenceRequirement(2, info.maxEssence), + ), + () => <>it would hit at least 4 targets, + ), + }; +}; +const threeTargetPyre = (info: TalentInfo): Rule => { + return { + spell: TALENTS.PYRE_TALENT, + + condition: cnd.describe( + cnd.and( + cnd.targetsHit( + { atLeast: 3 }, + { lookahead: 2000, targetType: EventType.Damage, targetSpell: SPELLS.PYRE }, + ), + cnd.or( + cnd.buffStacks(SPELLS.CHARGED_BLAST, { atLeast: 15 }), + cnd.hasTalent(TALENTS.VOLATILITY_TALENT), + ), + hasEssenceRequirement(2, info.maxEssence), + ), + () => ( + <> + it would hit at least 3 targets and you have at least 15 stacks of{' '} + or have{' '} + talented + + ), + ), + }; +}; +const disintegrate = (info: TalentInfo): Rule => { + return { + spell: SPELLS.DISINTEGRATE, + condition: cnd.describe( + hasEssenceRequirement(3, info.maxEssence), + (tense) => ( + <> + {tenseAlt( + tense, + <>, + <> + because you had at least 3 or{' '} + was present + , + )} + + ), + '', + ), + }; +}; +/** Ideally you only ever use this in true AoE (3T+) but we can only really + * make that check when we have Protracted Talons talented (Azure Strike hits 3 targets instead of only 2) + * So just fallback to 2 targets and assume AoE + * The difference is also kinda w/e at 2T - atleast until we get the filler Juicer Hero Talents monkaS */ +const aoeAzureStrike = (info: TalentInfo): Rule => { + return { + spell: SPELLS.AZURE_STRIKE, + condition: cnd.targetsHit( + { atLeast: info.hasProtractedTalons ? 3 : 2 }, + { lookahead: 1000, targetType: EventType.Damage, targetSpell: SPELLS.AZURE_STRIKE }, + ), + }; +}; diff --git a/src/analysis/retail/evoker/devastation/modules/Buffs.tsx b/src/analysis/retail/evoker/devastation/modules/Buffs.tsx index fbfda3c4580..0fae4dde460 100644 --- a/src/analysis/retail/evoker/devastation/modules/Buffs.tsx +++ b/src/analysis/retail/evoker/devastation/modules/Buffs.tsx @@ -3,54 +3,69 @@ import PRIEST_TALENTS from 'common/TALENTS/priest'; import BLOODLUST_BUFFS from 'game/BLOODLUST_BUFFS'; import CoreAuras from 'parser/core/modules/Auras'; import TALENTS from 'common/TALENTS/evoker'; +import { TIERS } from 'game/TIERS'; class Buffs extends CoreAuras { auras() { + const combatant = this.selectedCombatant; + return [ + // Cooldowns { - spellId: TALENTS.DRAGONRAGE_TALENT.id, // Dragonrage - timelineHighlight: true, - triggeredBySpellId: TALENTS.DRAGONRAGE_TALENT.id, - }, - { - spellId: SPELLS.HOVER.id, // Hover + spellId: TALENTS.DRAGONRAGE_TALENT.id, timelineHighlight: true, - triggeredBySpellId: SPELLS.HOVER.id, + enabled: combatant.hasTalent(TALENTS.DRAGONRAGE_TALENT), }, + // Rotational Buffs { spellId: SPELLS.POWER_SWELL_BUFF.id, - timelineHighlight: true, - triggeredBySpellId: SPELLS.POWER_SWELL_BUFF.id, + timelineHighlight: false, + enabled: combatant.hasTalent(TALENTS.POWER_SWELL_TALENT), }, { spellId: SPELLS.IRIDESCENCE_RED.id, timelineHighlight: false, - triggeredBySpellId: SPELLS.IRIDESCENCE_RED.id, + enabled: combatant.hasTalent(TALENTS.IRIDESCENCE_TALENT), }, { spellId: SPELLS.IRIDESCENCE_BLUE.id, timelineHighlight: false, - triggeredBySpellId: SPELLS.IRIDESCENCE_BLUE.id, + enabled: combatant.hasTalent(TALENTS.IRIDESCENCE_TALENT), }, { spellId: SPELLS.ESSENCE_BURST_DEV_BUFF.id, - triggeredBySpellId: SPELLS.ESSENCE_BURST_DEV_BUFF.id, timelineHighlight: true, }, + { + spellId: SPELLS.BURNOUT_BUFF.id, + timelineHighlight: true, + enabled: combatant.hasTalent(TALENTS.BURNOUT_TALENT), + }, + // Tier Set + { + spellId: SPELLS.EMERALD_TRANCE_T31_4PC_BUFF.id, + timelineHighlight: true, + enabled: combatant.has4PieceByTier(TIERS.DF3), + }, // Defensive { spellId: TALENTS.OBSIDIAN_SCALES_TALENT.id, - triggeredBySpellId: TALENTS.OBSIDIAN_SCALES_TALENT.id, timelineHighlight: false, + enabled: combatant.hasTalent(TALENTS.OBSIDIAN_SCALES_TALENT), }, { spellId: TALENTS.RENEWING_BLAZE_TALENT.id, - triggeredBySpellId: TALENTS.RENEWING_BLAZE_TALENT.id, timelineHighlight: false, + enabled: combatant.hasTalent(TALENTS.RENEWING_BLAZE_TALENT), + }, + // Util + { + spellId: SPELLS.HOVER.id, + timelineHighlight: true, }, + // Externals { spellId: PRIEST_TALENTS.POWER_INFUSION_TALENT.id, - triggeredBySpellId: PRIEST_TALENTS.POWER_INFUSION_TALENT.id, timelineHighlight: true, }, // Bloodlust diff --git a/src/analysis/retail/evoker/devastation/modules/guide/CoreRotation.tsx b/src/analysis/retail/evoker/devastation/modules/guide/CoreRotation.tsx index ccf90bded12..75fa23d853e 100644 --- a/src/analysis/retail/evoker/devastation/modules/guide/CoreRotation.tsx +++ b/src/analysis/retail/evoker/devastation/modules/guide/CoreRotation.tsx @@ -1,6 +1,6 @@ import { GuideProps, Section } from 'interface/guide'; import { AplSectionData } from 'interface/guide/components/Apl'; -import * as AplCheck from '../AplCheck'; +import * as AplCheck from '../AplCheck/AplCheck'; import { ResourceLink, SpellLink } from 'interface'; import { TALENTS_EVOKER } from 'common/TALENTS'; import RESOURCE_TYPES from 'game/RESOURCE_TYPES'; @@ -20,10 +20,10 @@ export function CoreRotation({ modules, info }: GuideProps

- This Action Prioriy List (APL) is based off the Devastation APL found at{' '} + This Action Priority List (APL) is based off the Devastation APL found at{' '} WyrmrestTemple.

- +
As mentioned before use the accuracy here as a reference point to compare to other logs. diff --git a/src/analysis/retail/evoker/devastation/modules/normalizers/CastLinkNormalizer.ts b/src/analysis/retail/evoker/devastation/modules/normalizers/CastLinkNormalizer.ts index 90261c042cd..9c3c5d9c65f 100644 --- a/src/analysis/retail/evoker/devastation/modules/normalizers/CastLinkNormalizer.ts +++ b/src/analysis/retail/evoker/devastation/modules/normalizers/CastLinkNormalizer.ts @@ -46,6 +46,7 @@ const EVENT_LINKS: EventLink[] = [ anyTarget: true, forwardBufferMs: CAST_BUFFER_MS, backwardBufferMs: CAST_BUFFER_MS, + maximumLinks: 1, isActive(c) { return c.hasTalent(TALENTS_EVOKER.BURNOUT_TALENT); }, diff --git a/src/analysis/retail/evoker/devastation/modules/normalizers/EssenceBurstNormalizer.ts b/src/analysis/retail/evoker/devastation/modules/normalizers/EssenceBurstNormalizer.ts index eeb98852c53..5205ae73477 100644 --- a/src/analysis/retail/evoker/devastation/modules/normalizers/EssenceBurstNormalizer.ts +++ b/src/analysis/retail/evoker/devastation/modules/normalizers/EssenceBurstNormalizer.ts @@ -5,17 +5,19 @@ import { Options } from 'parser/core/Module'; const EVENT_ORDERS: EventOrder[] = [ { - beforeEventId: SPELLS.SHATTERING_STAR.id, + beforeEventId: [SPELLS.SHATTERING_STAR.id, SPELLS.LIVING_FLAME_CAST.id], beforeEventType: EventType.Cast, afterEventId: SPELLS.ESSENCE_BURST_DEV_BUFF.id, - afterEventType: [EventType.ApplyBuffStack, EventType.ApplyBuff], + afterEventType: [EventType.ApplyBuffStack, EventType.ApplyBuff, EventType.RefreshBuff], bufferMs: 50, anyTarget: true, + updateTimestamp: true, }, ]; /** * The applybuff from Arcane Vigor is logged before the cast of Shattering Star + * This also happens to Living Flames cast with Burnout * This normalizes events so that the Shattering Star cast always comes before the EB buff **/ class EssenceBurstNormalizer extends EventOrderNormalizer { diff --git a/src/analysis/retail/evoker/shared/constants.ts b/src/analysis/retail/evoker/shared/constants.ts index ca31f13933b..b5fcd6bc14a 100644 --- a/src/analysis/retail/evoker/shared/constants.ts +++ b/src/analysis/retail/evoker/shared/constants.ts @@ -5,3 +5,5 @@ export const INNATE_MAGIC_REGEN = 0.05; export const BASE_MAX_ESSENCE = 5; export const POTENT_MANA_MULTIPLIER = 0.03; + +export const BASE_EVOKER_RANGE = 25; diff --git a/src/analysis/retail/evoker/shared/modules/Abilities.tsx b/src/analysis/retail/evoker/shared/modules/Abilities.tsx index bbc1a0dcf0b..c2f9a843fba 100644 --- a/src/analysis/retail/evoker/shared/modules/Abilities.tsx +++ b/src/analysis/retail/evoker/shared/modules/Abilities.tsx @@ -7,6 +7,7 @@ import { SpellbookAbility } from 'parser/core/modules/Ability'; import SPELL_CATEGORY from 'parser/core/SPELL_CATEGORY'; import spells from 'common/SPELLS/dragonflight/trinkets'; import trinkets from 'common/ITEMS/dragonflight/trinkets'; +import { BASE_EVOKER_RANGE } from '../constants'; const hasFont = (combatant: Combatant) => combatant.hasTalent(TALENTS.FONT_OF_MAGIC_PRESERVATION_TALENT) || @@ -31,6 +32,7 @@ class Abilities extends CoreAbilities { gcd: { base: 1500, }, + range: BASE_EVOKER_RANGE, enabled: combatant.spec !== SPECS.AUGMENTATION_EVOKER, }, { @@ -41,6 +43,7 @@ class Abilities extends CoreAbilities { gcd: { base: 1500, }, + range: BASE_EVOKER_RANGE, damageSpellIds: [SPELLS.EMERALD_BLOSSOM_CAST.id], isDefensive: true, }, @@ -62,6 +65,7 @@ class Abilities extends CoreAbilities { recommendedEfficiency: 0.95, }, }), + range: BASE_EVOKER_RANGE, }, { spell: SPELLS.LIVING_FLAME_CAST.id, @@ -69,6 +73,7 @@ class Abilities extends CoreAbilities { gcd: { base: 1500, }, + range: BASE_EVOKER_RANGE, damageSpellIds: [SPELLS.LIVING_FLAME_DAMAGE.id], }, { @@ -78,6 +83,7 @@ class Abilities extends CoreAbilities { ? SPELL_CATEGORY.HEALER_DAMAGING_SPELL : SPELL_CATEGORY.ROTATIONAL, cooldown: 0, + range: BASE_EVOKER_RANGE, gcd: { base: 1500, }, @@ -181,6 +187,7 @@ class Abilities extends CoreAbilities { gcd: { base: 1500, }, + range: BASE_EVOKER_RANGE, }, { spell: TALENTS.OPPRESSING_ROAR_TALENT.id, diff --git a/src/parser/shared/metrics/apl/conditions/buffSoonPresent.tsx b/src/parser/shared/metrics/apl/conditions/buffSoonPresent.tsx new file mode 100644 index 00000000000..7aec8617563 --- /dev/null +++ b/src/parser/shared/metrics/apl/conditions/buffSoonPresent.tsx @@ -0,0 +1,87 @@ +import type Spell from 'common/SPELLS/Spell'; +import { SpellLink } from 'interface'; +import { EventType } from 'parser/core/Events'; +import { Condition, tenseAlt } from '../index'; +import { formatTimestampRange, Range } from './util'; +import { TrackedBuffEvent } from 'parser/core/Entity'; + +type BuffInformation = { + buffs: TrackedBuffEvent[]; + nextBuff: number | undefined; + currentIndex: number; +}; + +type SourceOptions = 'player' | 'external' | 'any'; + +/** + * Check if a buff will be present soon after the present time. + * + * This should ONLY be used for buffs that the player has complete control over, such as buffs tied to cooldown use. + * DO NOT use this to "look ahead" for random procs to implement impossibly optimal APLs. + */ +export function buffSoonPresent( + spell: Spell, + range: Range, + /** Set to configure the source of the buff */ + source: SourceOptions = 'player', +): Condition { + return { + key: `buffSoonPresent-${spell.id}-${range.atLeast}-${range.atMost}-${source}`, + init: ({ combatant }) => { + const buffs = combatant?.buffs + ? combatant.buffs.filter( + (buff) => + buff.ability.guid === spell.id && isValidSource(source, combatant.id, buff.sourceID), + ) + : []; + + return { + buffs, + nextBuff: buffs[0]?.timestamp, + currentIndex: 0, + }; + }, + update: (state, event) => { + if (event.type !== EventType.ApplyBuff || event.ability.guid !== spell.id) { + return state; + } + + state.currentIndex += 1; + state.nextBuff = state.buffs[state.currentIndex]?.timestamp; + + return state; + }, + validate: (state, event) => { + /** No more buffs or no buffs found. + * We will assume no more buffs will come so only atLeast can be true */ + if (!state.nextBuff) { + return Boolean(!range.atMost && range.atLeast); + } + + const timeTillNextBuff = state.nextBuff - event.timestamp; + return ( + timeTillNextBuff >= (range.atLeast || 0) && + (!range.atMost || timeTillNextBuff <= range.atMost) + ); + }, + describe: (tense) => ( + <> + there {tenseAlt(tense, 'is', 'was')} {formatTimestampRange(range)} seconds remaining before{' '} + {tenseAlt(tense, 'is', 'was')} present + + ), + }; +} + +function isValidSource(source: SourceOptions, playerId: number, buffSourceId?: number): boolean { + switch (source) { + case 'any': + return true; + case 'player': + return buffSourceId === playerId; + case 'external': + return buffSourceId !== playerId; + default: + return false; + } +} diff --git a/src/parser/shared/metrics/apl/conditions/debuffMissing.tsx b/src/parser/shared/metrics/apl/conditions/debuffMissing.tsx index ab1f3c02fc9..c26ac5323c8 100644 --- a/src/parser/shared/metrics/apl/conditions/debuffMissing.tsx +++ b/src/parser/shared/metrics/apl/conditions/debuffMissing.tsx @@ -33,11 +33,14 @@ export function getTargets(event: AplTriggerEvent, targetLink?: string): string[ The rule applies when the debuff `spell` is missing. The `optPandemic` parameter gives the ability to allow early refreshes to prevent a debuff dropping, but this will not *require* early refreshes. + + fallback defines the output when there is no target/the debuff hasn't been seen yet **/ export function debuffMissing( spell: Spell, optPandemic?: PandemicData, targetOptions?: TargetOptions, + fallback: boolean = false, ): Condition<{ [key: string]: DurationData }> { return { key: `debuffMissing-${spell.id}`, @@ -73,7 +76,7 @@ export function debuffMissing( const targets = getTargets(event, targetOptions?.targetLinkRelation); if (targets.length === 0) { //No target so we can't check for a debuff - return false; + return fallback; } return targets.some((encodedTargetString) => diff --git a/src/parser/shared/metrics/apl/conditions/hasResource.tsx b/src/parser/shared/metrics/apl/conditions/hasResource.tsx index d0aafbed439..cd2f7a20ed1 100644 --- a/src/parser/shared/metrics/apl/conditions/hasResource.tsx +++ b/src/parser/shared/metrics/apl/conditions/hasResource.tsx @@ -15,10 +15,15 @@ const rangeSatisfied = (actualAmount: number, range: Range): boolean => // NOTE: this doesn't explicitly model natural regen (mana, energy, focus) but // when the classResources are present it does use those as the main source of // truth, which should accomodate them in the vast majority of cases. -export default function hasResource(resource: Resource, range: Range): Condition { +// use initial to set the expected initial resources on fight start +export default function hasResource( + resource: Resource, + range: Range, + initial?: number, +): Condition { return { key: `hasResource-${resource.id}`, - init: () => 0, + init: () => initial ?? 0, update: (state, event) => { if (event.type === EventType.ResourceChange && event.resourceChangeType === resource.id) { return event.resourceChange - event.waste + state; diff --git a/src/parser/shared/metrics/apl/conditions/index.tsx b/src/parser/shared/metrics/apl/conditions/index.tsx index fe68b60e932..82ce520a77a 100644 --- a/src/parser/shared/metrics/apl/conditions/index.tsx +++ b/src/parser/shared/metrics/apl/conditions/index.tsx @@ -23,3 +23,4 @@ export { lastSpellCast } from './lastSpellCast'; export { default as describe } from './describe'; export * from './util'; export { has2PieceByTier, has4PieceByTier } from './hasTier'; +export { buffSoonPresent } from './buffSoonPresent'; diff --git a/src/parser/shared/metrics/apl/index.ts b/src/parser/shared/metrics/apl/index.ts index 6f5d8709226..752c5b4aeb1 100644 --- a/src/parser/shared/metrics/apl/index.ts +++ b/src/parser/shared/metrics/apl/index.ts @@ -408,11 +408,32 @@ export function aplProcessesEvent( applicableSpells: Set, playerId: number, ): event is BeginChannelEvent | CastEvent { + /** Checks if a spell is a chain cast/channel + * Without this check most(if not all) back-to-back cast of channeled/non-instant casts of the same ability + * would be treated as the same cast, and therefore would skip rule check on those casts + * + * The reason for two checks is essentially just to make sure that we always use the same event type + * for our rule checks, should help avoid unwanted interactions */ + const isChainCast = + event.type === EventType.Cast && + event !== result.mostRecentBeginCast?.trigger && + result.mostRecentBeginCast?.trigger?.type !== EventType.BeginCast && + result.mostRecentBeginCast?.ability.guid === event.ability.guid && + /** Ignore these until Empowers are properly normalized */ + result.mostRecentBeginCast?.trigger?.type !== EventType.EmpowerStart; + + const isChainChannel = + event.type === EventType.BeginChannel && + event.trigger !== result.mostRecentCast && + result?.mostRecentCast?.ability.guid === event.ability.guid; + return ( ((event.type === EventType.BeginChannel && event.ability.guid !== result.mostRecentCast?.ability.guid) || (event.type === EventType.Cast && - event.ability.guid !== result.mostRecentBeginCast?.ability.guid)) && + event.ability.guid !== result.mostRecentBeginCast?.ability.guid) || + isChainCast || + isChainChannel) && applicableSpells.has(event.ability.guid) && event.sourceID === playerId );