From fd262245bb87f436008d0c8325774bdf4deb42df Mon Sep 17 00:00:00 2001 From: Roland Salardon Date: Sun, 9 Aug 2026 19:45:25 +0200 Subject: [PATCH 1/2] fix(ei-compare): drop interrupted breakbar-only skills from connected hits EI books a breakbar-only skill as interrupted (connectedHits = 0) when the player interrupted an enemy cast with that skill, instead of counting the result-10 ticks. Skip breakbar-only counting for skills with a matching InterruptEvent from the same source within the slice. Verified across the 35-log corpus: the two remaining connectedHits diffs (skills 5930 / 12621 on 20260508-001302) are the only such cases, and the new rule clears both without touching any other log. Parity 179 -> 177. Signed-off-by: Roland Salardon --- .../src/gw2_analytics/ei_compare.py | 30 +++++++++++++++---- libs/gw2_analytics/tests/test_ei_compare.py | 8 +++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/libs/gw2_analytics/src/gw2_analytics/ei_compare.py b/libs/gw2_analytics/src/gw2_analytics/ei_compare.py index 429ac658..b9658568 100644 --- a/libs/gw2_analytics/src/gw2_analytics/ei_compare.py +++ b/libs/gw2_analytics/src/gw2_analytics/ei_compare.py @@ -29,6 +29,7 @@ Event, Fight, HealthUpdateEvent, + InterruptEvent, Profession, UpEvent, spec_display_name, @@ -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) @@ -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) @@ -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] = { @@ -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)] @@ -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) ] @@ -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( diff --git a/libs/gw2_analytics/tests/test_ei_compare.py b/libs/gw2_analytics/tests/test_ei_compare.py index fba0eca4..5aec6b10 100644 --- a/libs/gw2_analytics/tests/test_ei_compare.py +++ b/libs/gw2_analytics/tests/test_ei_compare.py @@ -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 From c55fea460cec12a1328dbfe07ded581ec8aa8f3b Mon Sep 17 00:00:00 2001 From: Roland Salardon Date: Mon, 10 Aug 2026 02:49:21 +0200 Subject: [PATCH 2/2] fix(parser): treat is_offcycle buff records as extensions, graft onto closest stack EI converts arcdps buff records with is_offcycle != 0 (a refresh on a living stack) into BuffExtensionEvent that adds duration to the existing stack closest to oldValue, without consuming a slot or evicting. The tracker was emitting them as fresh BoonApplyEvents that got evicted at the 25-stack cap, losing ~0.8 average might stacks vs EI (11.24 vs 12.07 on the 2026-03-09 log). - parser: emit BuffExtensionEvent(extended=value, new=overstack) for is_offcycle applies, mirroring the statechange-70 path. - buff_state: _process_buff_extension now grafts onto the stack closest to old_duration (EI MinBy(|TotalDuration - oldValue|)) instead of the shortest expiry. Uptime 11.241 -> 11.771. Parser tests 188 passed, buff_state 32 passed. Signed-off-by: Roland Salardon --- .../src/gw2_analytics/buff_state.py | 9 +++++++-- .../src/gw2_evtc_parser/parser.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/libs/gw2_analytics/src/gw2_analytics/buff_state.py b/libs/gw2_analytics/src/gw2_analytics/buff_state.py index 9d3ec448..e2200e8c 100644 --- a/libs/gw2_analytics/src/gw2_analytics/buff_state.py +++ b/libs/gw2_analytics/src/gw2_analytics/buff_state.py @@ -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) diff --git a/libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py b/libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py index 6f4c65df..36b367b2 100644 --- a/libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py +++ b/libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py @@ -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,