Skip to content
This repository was archived by the owner on Oct 11, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions libs/gw2_analytics/src/gw2_analytics/buff_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,8 +519,13 @@ def _process_buff_extension(self, event: BuffExtensionEvent) -> None:
if target_tracker.expirations and (
old_duration > 0 or len(target_tracker.expirations) >= _capacity_for(buff_name)
):
if target_tracker.expirations[0] is not None:
target_tracker.expirations[0] += event.extended_duration_ms
# EI BuffSimulatorIntensity.Extend grafts the extension onto the
# stack whose remaining duration is closest to oldValue, not the
# shortest one.
candidates = [(i, e) for i, e in enumerate(target_tracker.expirations) if e is not None]
if candidates:
index, remaining = min(candidates, key=lambda pair: abs(pair[1] - old_duration))
target_tracker.expirations[index] = remaining + event.extended_duration_ms
return
target_tracker.expirations.append(event.new_duration_ms)
target_tracker.stack_ids.append(event.stack_id)
Expand Down
30 changes: 25 additions & 5 deletions libs/gw2_analytics/src/gw2_analytics/ei_compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
Event,
Fight,
HealthUpdateEvent,
InterruptEvent,
Profession,
UpEvent,
spec_display_name,
Expand Down Expand Up @@ -174,7 +175,9 @@ def _condition_damage(event: DamageEvent, condition_skill_ids: set[int] | None)
return min(event.damage, event.buff_dmg)


def _skill_stats(events: list[DamageEvent]) -> dict[int, dict[str, int]]:
def _skill_stats(
events: list[DamageEvent], interrupted_skills: set[int] | None = None
) -> dict[int, dict[str, int]]:
by_skill: dict[int, list[DamageEvent]] = defaultdict(list)
for event in events:
by_skill[event.skill_id].append(event)
Expand All @@ -193,6 +196,14 @@ def _skill_stats(events: list[DamageEvent]) -> dict[int, dict[str, int]]:
# Verified across the 35-log corpus: 45 over-counts (mixed normals
# and breakbar) and 8 under-counts (breakbar-only) and 7 misses
# (breakbar + blocked/evaded only) all explained by this rule.
#
# One further corner: when the player interrupted an enemy cast
# with that skill (an InterruptEvent from the same source), EI
# books the entry as ``interrupted`` and drops the breakbar-only
# hit, so connectedHits = 0. Only breakbar-only skills can hit
# this; a skill with landed normals keeps the normal count.
# Verified across the corpus: the only two such cases (skills
# 5930 / 12621 on 20260508-001302) are the sole remaining diffs.
direct = [e for e in skill_events if not e.is_condition]
condi = [e for e in skill_events if e.is_condition]
n_norm_direct = sum(e.result in {0, 1, 2} for e in direct)
Expand All @@ -202,11 +213,14 @@ def _skill_stats(events: list[DamageEvent]) -> dict[int, dict[str, int]]:
if n_norm_direct + n_condi_landed > 0:
# Normals exist: EI counts the normals and skips breakbar ticks.
connected = n_norm_direct + n_condi_landed
elif n_other_failed == 0:
# Only breakbar hits: EI counts them.
elif n_other_failed == 0 and skill_id not in (interrupted_skills or ()):
# Only breakbar hits: EI counts them -- unless the skill
# interrupted an enemy cast, which books the entry as
# interrupted (connectedHits = 0).
connected = n_breakbar + n_condi_landed
else:
# Breakbar plus blocked/evaded/blind only: EI drops the skill.
# Breakbar plus blocked/evaded/blind only, or an interrupting
# breakbar skill: EI drops the skill.
connected = 0
direct_crits = [event for event in direct if event.result in {1, 8} and event.buff_dmg == 0]
result[skill_id] = {
Expand Down Expand Up @@ -337,6 +351,7 @@ def compare_elite_insights( # noqa: PLR0912, PLR0915
damage_events = [event for event in event_list if isinstance(event, DamageEvent)]
actor_damage_events = [event for event in damage_events if event.src_master_instid == 0]
cc_events = [event for event in event_list if isinstance(event, CCEvent)]
interrupt_events = [event for event in event_list if isinstance(event, InterruptEvent)]
down_events = [event for event in event_list if isinstance(event, DownEvent)]
death_events = [event for event in event_list if isinstance(event, DeathEvent)]
health_events = [event for event in event_list if isinstance(event, HealthUpdateEvent)]
Expand Down Expand Up @@ -648,6 +663,11 @@ def in_slice(event: Event, _lo: int = slice_lo, _hi: int = slice_hi) -> bool:
and in_slice(event)
]
actor_damage = [event for event in source_damage if event.src_master_instid == 0]
source_interrupts = {
event.skill_id
for event in interrupt_events
if event.source_agent_id in agent_ids and in_slice(event)
}
source_cc = [
event for event in cc_events if event.source_agent_id in agent_ids and in_slice(event)
]
Expand Down Expand Up @@ -977,7 +997,7 @@ def in_slice(event: Event, _lo: int = slice_lo, _hi: int = slice_hi) -> bool:

damage_dist = player.get("totalDamageDist")
if isinstance(damage_dist, list) and damage_dist and isinstance(damage_dist[0], list):
actual_skills = _skill_stats(actor_damage)
actual_skills = _skill_stats(actor_damage, source_interrupts)
values["totalDamageDist"] = actual_skills
for expected_skill in damage_dist[0]:
if not isinstance(expected_skill, dict) or not isinstance(
Expand Down
8 changes: 8 additions & 0 deletions libs/gw2_analytics/tests/test_ei_compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,14 @@ def test_skill_stats_breakbar_grouprule() -> None:
# Breakbar only: EI counts them.
stats = _skill_stats([_dmg(10, True)])
assert stats[42]["connectedHits"] == 1
# Breakbar only + the player interrupted an enemy cast with that
# skill: EI books the entry as interrupted and drops the hit.
stats = _skill_stats([_dmg(10, True)], {42})
assert stats[42]["connectedHits"] == 0
# A landed normal keeps the normal count even when the skill
# interrupted: interrupt only affects the breakbar-only branch.
stats = _skill_stats([_dmg(1, True), _dmg(10, True)], {42})
assert stats[42]["connectedHits"] == 1
# Breakbar + blocked/evaded only: EI drops the skill entirely.
stats = _skill_stats([_dmg(10, True), _dmg(3, False)])
assert stats[42]["connectedHits"] == 0
Expand Down
18 changes: 18 additions & 0 deletions libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -1171,6 +1171,24 @@ def parse_events(source: BinaryIO | bytes) -> Iterator[Event]: # noqa: PLR0912,
# use buff_dmg. Crowd-control and activation records carry
# values but are not health damage.
if _ev_buff and buff_dmg == 0 and value > 0 and is_activation == 0:
# arcdps marks a buff REFRESH on a living stack with
# is_offcycle != 0; EI converts these to BuffExtensionEvent
# (extension into the closest existing stack, no new slot).
if is_offcycle:
yield BuffExtensionEvent(
time_ms=time_ms,
source_agent_id=src_agent,
target_agent_id=dst_agent,
skill_id=skill_id,
extended_duration_ms=(
0 if value >= _DAMAGE_SANITY_CAP else max(0, value)
),
new_duration_ms=(
0 if overstack >= _DAMAGE_SANITY_CAP else max(0, overstack)
),
stack_id=pad,
)
continue
yield BoonApplyEvent(
time_ms=time_ms,
source_agent_id=src_agent,
Expand Down
Loading