EventLinkNormalizer - WoWAnalyzer/WoWAnalyzer GitHub Wiki

This page is intended for people who already have some experience developing for WoWAnalyzer. It discusses a common problem during analyses, and an abstract class that may help solve the problem.

The problem

Sometimes in order to completely analyze a situation, we want to simultaneously look at multiple related events - for example a CastEvent, the DamageEvent that it caused, and a RemoveBuffEvent for the proc it consumed. However, Analyzers are based on an 'event listener' system, so we must handle one event at a time. Traditionally, this might be done by saving recent events that fit the pattern, and checking for timestamp proximity something like this:

lastCast: CastEvent | undefined;
lastProcConsume: RemoveBuffEvent | undefined;

constructor(options: Options) {
  super(options);
  this.addEventListener(Events.damage.by(SELECTED_PLAYER).spell(SPELLS.MY_ABILITY_DAMAGE), 
      this.onDamageMyAbility);
  this.addEventListener(Events.cast.by(SELECTED_PLAYER).spell(SPELLS.MY_ABILITY_CAST),
      this.onCastMyAbility);
  this.addEventListener(Events.removebuff.by(SELECTED_PLAYER).spell(SPELLS.MY_PROC),
      this.onRemoveBuffMyAbility);
}

onCastMyAbility(event) {
  this.lastCast = event;
}

onRemoveBuffMyAbility(event) {
  this.lastProcConsume = event;
}

onDamageMyAbility(event) {
  // assume cast and proc consume happen before damage
  const timestamp = event.timestamp;
  const usedProc = this.lastProcConsume && this.lastProcConsume.timestamp + BUFFER_MS > timestamp;

  // We now have the DamageEvent, CastEvent, and whether there was a proc - DO ANALYSIS HERE
}

The first problem with this is the assumption that the cast and proc consumption will happen before the damage - in fact when multiple events happen at roughly the same time, they can happen in virtually any order. This would typically be solved with an EventOrderNormalizer which rearranges the event stream to ensure the events happen in an order to make analysis consistent:

const EVENT_ORDERS: EventOrder[] = [
  {
    beforeEventId: SPELLS.MY_ABILITY_CAST.id,
    beforeEventType: EventType.Cast,
    afterEventId: SPELLS.MY_ABILITY_DAMAGE.id,
    afterEventType: EventType.Damage,
    bufferMs: BUFFER_MS,
  },
  {
    beforeEventId: SPELLS.MY_PROC.id,
    beforeEventType: EventType.RemoveBuff,
    afterEventId: SPELLS.MY_ABILITY_DAMAGE.id,
    afterEventType: EventType.Damage,
    bufferMs: BUFFER_MS,
  },
];

class MyAbilityNormalizer extends EventOrderNormalizer {
  constructor(options: Options) {
    super(options, EVENT_ORDERS);
  }
}

Doing reordering has problems of its own however

  • It messes with the strict linear progression of timestamps, causing possible esoteric bugs in modules that use this.owner.timestamp
  • Reordering can scale poorly and often gets unmanageable as multi-event analyses get more complex.
  • Multiple reorder normalizers can potentially interfere with each other (one normalizer's reorder "undoes" the work of another in specific conditions - this has happened before)
  • It might be clear now that the normalizer and analyzer are tied together, but that relation isn't explicit. On its own, it's not clear why the Normalizer is reordering events. On its own, it's not clear that the Analyzer relies on a specific order.

The solution

The EventOrderNormalizer is already using time proximity to relate events - what if instead of re-ordering them, we added direct links from one event to the other? This is the EventLinkNormalizer. Relations are specified in much the same way as the EventOrderNormalizer, but when a relation is found we add one event to the other's _linkedEvents field. An event can link to multiple other events, and links are keyed to allow links with multiple different meanings. EventLinkNormalizer's helper methods take care of the busywork. After a refactor, the above problem can look like this:

Normalizer:

const FROM_HARDCAST = 'FromHardcast';
const CONSUMED_PROC = 'ConsumedProc';
const EVENT_LINKS: EventLink[] = [
  {
    linkRelation: FROM_HARDCAST,
    referencedEventId: SPELLS.MY_ABILITY_CAST.id,
    referencedEventType: EventType.Cast,
    linkingEventId: SPELLS.MY_ABILITY_DAMAGE.id,
    linkingEventType: EventType.Damage,
    forwardBufferMs: BUFFER_MS,
    backwardBufferMs: BUFFER_MS,
  },
  {
    linkRelation: CONSUMED_PROC,
    referencedEventId: SPELLS.MY_PROC.id,
    referencedEventType: EventType.RemoveBuff,
    linkingEventId: SPELLS.MY_ABILITY_DAMAGE.id,
    linkingEventType: EventType.Damage,
    forwardBufferMs: BUFFER_MS,
    backwardBufferMs: BUFFER_MS,
    anyTarget: true, // remove buff targets the player, damage targets the enemy
  },
];

class MyAbilityNormalizer extends EventLinkNormalizer {
  constructor(options: Options) {
    super(options, EVENT_LINKS);
  }
}

export function getHardcast(event: DamageEvent): CastEvent | undefined {
  return GetRelatedEvents(event, FROM_HARDCAST)
    .filter((e): e is CastEvent => e.type === EventType.Cast)
    .pop();
}

export function consumedProc(event: DamageEvent): boolean {
  return HasRelatedEvent(event, CONSUMED_PROC);
}

Analyzer:

constructor(options: Options) {
  super(options);
  this.addEventListener(Events.damage.by(SELECTED_PLAYER).spell(SPELLS.MY_ABILITY_DAMAGE),
      this.onDamageMyAbility);
}

onDamageMyAbility(event) {
  const usedProc = consumedProc(event);
  const hardcast = getHardcast(event);

  // We now have the DamageEvent, CastEvent, and whether there was a proc - DO ANALYSIS HERE
}

Note that the Analyzer can now focus entirely on the analysis, and doesn't have to keep track of a lot of state. Also notice that the module is now explicitly tied by dependency to the EventLinkNormalizer because of its use of consumedProc and getHardcast.

Other Examples

Below are some more examples of what can be done with EventLinkNormalizer

Track number of targets hit with an AoE ability

We want to make sure the player is using their AoE ability correctly (in range of targets, don't use in single target). Doing the average calculation (cast event count / damage event count) will fail to detect when an individual cast was incorrect.

Normalizer

const EVENT_LINKS: EventLink[] = [
  {
    linkRelation: HIT_TARGET,
    linkingEventId: SPELLS.MY_AOE.id,
    linkingEventType: EventType.Cast,
    referencedEventId: SPELLS.MY_AOE.id,
    referencedEventType: EventType.Damage,
    forwardBufferMs: CAST_BUFFER_MS,
    backwardBufferMs: CAST_BUFFER_MS,
    anyTarget: true, // by default, EventLinkNormalizer only links events with same target
  },
];

class AoeHitLinker extends EventLinkNormalizer {
  constructor(options: Options) {
    super(options, EVENT_LINKS);
  }
}

export function getHitCount(aoeCastEvent: CastEvent): number {
  return GetRelatedEvents(aoeCastEvent, HIT_TARGET).length;
}

Analyzer:

...

onCastMyAoe(event: CastEvent) {
  const hits = getHitCount(event);
  if (hits === 0) {
    // Tally for Guide / suggestion - player wasn't in range to hit anything!
  } else if (hits === 1) {
    // Tally for Guide / suggestion - don't use MY_AOE in single target!
  }
  // more analysis on number targets hit...
}

Get extra energy used

Consider the hypothetical ability ANGRY_CHOMP, which consumes up to 25 extra energy in exchange for more damage. The correct play is to always use the full amount of extra energy, and we want to highlight bad casts in the timeline. Unfortunately, the extra energy consumption isn't in the CastEvent, it's in a separate DrainEvent.

Normalizer:

const EVENT_LINKS: EventLink[] = [
  {
    linkRelation: ADDITIONAL_ENERGY_USED,
    linkingEventId: SPELLS.ANGRY_CHOMP.id,
    linkingEventType: EventType.Cast,
    referencedEventId: SPELLS.ANGRY_CHOMP.id,
    referencedEventType: EventType.Drain,
    anyTarget: true, // the drain targets the player, the cast targets an enemy
    forwardBufferMs: BUFFER_MS,
    backwardBufferMs: BUFFER_MS,
  },
];

class AngryChompDrainLinkNormalizer extends EventLinkNormalizer {
  constructor(options: Options) {
    super(options, EVENT_LINKS);
  }
}

export function getAdditionalEnergyUsed(event: CastEvent): number {
  const events: AnyEvent[] = GetRelatedEvents(event, ADDITIONAL_ENERGY_USED);
  return events.length === 0 ? 0 : -(events[0] as DrainEvent).resourceChange;
}

Analyzer:

...

onAngryChompCast(event: CastEvent) {
  const extraEnergy = getAdditionalEnergyUsed(event);
  if (extraEnergy < MAX_EXTRA_ENERGY) {
    event.meta = ... // mark cast bad and fill in reason
  }
}