diff --git a/libs/gw2_analytics/src/gw2_analytics/buff_state.py b/libs/gw2_analytics/src/gw2_analytics/buff_state.py index e2200e8c..7e35ca85 100644 --- a/libs/gw2_analytics/src/gw2_analytics/buff_state.py +++ b/libs/gw2_analytics/src/gw2_analytics/buff_state.py @@ -506,6 +506,11 @@ def _process_buff_apply(self, event: BuffApplyEvent) -> None: del target_tracker.expirations[_capacity_for(buff_name) :] del target_tracker.stack_ids[_capacity_for(buff_name) :] del target_tracker.healing_scores[_capacity_for(buff_name) :] + if event.added_active and event.stack_id in target_tracker.stack_ids: + index = target_tracker.stack_ids.index(event.stack_id) + target_tracker.expirations.insert(0, target_tracker.expirations.pop(index)) + target_tracker.stack_ids.insert(0, target_tracker.stack_ids.pop(index)) + target_tracker.healing_scores.insert(0, target_tracker.healing_scores.pop(index)) def _process_buff_extension(self, event: BuffExtensionEvent) -> None: buff_name = _get_buff_name(event.skill_id) diff --git a/libs/gw2_analytics/src/gw2_analytics/down_contribution.py b/libs/gw2_analytics/src/gw2_analytics/down_contribution.py index 3d97e6ca..191f8e4f 100644 --- a/libs/gw2_analytics/src/gw2_analytics/down_contribution.py +++ b/libs/gw2_analytics/src/gw2_analytics/down_contribution.py @@ -207,6 +207,7 @@ def _pre_down_windows( ), default=-1, ) + saw_above_90 = False for health in health_by_target.get(down.source_agent_id, []): if health.time_ms <= last_up: continue @@ -214,8 +215,16 @@ def _pre_down_windows( break if health.health_percent > 90.0: start = None + saw_above_90 = True elif start is None: start = health.time_ms + # Elite Insights' IsDownedBeforeNext90 treats a hit delivered before + # the target's first HealthUpdate as hitting at unknown HP (<=90%), + # so the pre-down window opens right after the last rally even when + # no HealthUpdate has been seen yet. Without this, parity with EI is + # off by the damage dealt before the first recorded HealthUpdate. + if not saw_above_90: + start = last_up + 1 if start is not None: windows[down.source_agent_id].append((start, down.time_ms)) return windows diff --git a/libs/gw2_analytics/tests/test_buff_state.py b/libs/gw2_analytics/tests/test_buff_state.py index cc904ba8..7120cb28 100644 --- a/libs/gw2_analytics/tests/test_buff_state.py +++ b/libs/gw2_analytics/tests/test_buff_state.py @@ -450,6 +450,34 @@ def apply(time_ms: int, duration_ms: int, stack_id: int) -> BoonApplyEvent: assert tracker.compute_player_uptimes(7, 3_000)["regeneration"] == 100.0 +def test_initial_regeneration_active_stack_starts_at_front() -> None: + tracker = BuffStateTracker() + + tracker.process( + BuffApplyEvent( + time_ms=0, + source_agent_id=1, + target_agent_id=7, + skill_id=718, + duration_ms=1_000, + stack_id=11, + ) + ) + tracker.process( + BuffApplyEvent( + time_ms=0, + source_agent_id=1, + target_agent_id=7, + skill_id=718, + duration_ms=5_000, + stack_id=22, + added_active=True, + ) + ) + + assert tracker.compute_player_uptimes(7, 3_000)["regeneration"] == 100.0 + + def _regen_apply(time_ms: int, duration_ms: int, stack_id: int) -> BoonApplyEvent: return BoonApplyEvent( time_ms=time_ms, diff --git a/libs/gw2_analytics/tests/test_down_contribution.py b/libs/gw2_analytics/tests/test_down_contribution.py index dcc1f2aa..a7aded84 100644 --- a/libs/gw2_analytics/tests/test_down_contribution.py +++ b/libs/gw2_analytics/tests/test_down_contribution.py @@ -391,6 +391,47 @@ def contribution(death_time_ms: int) -> int: assert contribution(1_001) == 500 +def test_pre_down_damage_before_first_health_update_counts() -> None: + """EI treats HP as unknown (<=90%) before the first HealthUpdate. + + Parity regression on ``20260213-213832``: creative.1094 (agent 2980) + lands 236 dmg at t=35, before the target's first HealthUpdate at t=88. + EI's ``IsDownedBeforeNext90`` counts it because the target will down + before its next >90% recovery -- even though the hit precedes every + recorded health reading. Before the fix we opened the pre-down window + at the first <=90% HealthUpdate and reported 267 instead of the EI + value of 503. + """ + rows = DownContributionAggregator().aggregate( + [ + _damage(source=1, target=2, damage=236, time_ms=35), + _damage(source=1, target=2, damage=267, time_ms=260), + DamageEvent( + time_ms=6_318, + source_agent_id=1, + target_agent_id=2, + skill_id=71892, + damage=201, + against_downed=True, + ), + ], + [_down(agent=2, time_ms=2_747)], + [_death(agent=2, time_ms=8_681)], + duration_s=900.0, + health_events=[ + HealthUpdateEvent( + time_ms=88, source_agent_id=2, target_agent_id=0, skill_id=0, health_percent=13.46 + ), + HealthUpdateEvent( + time_ms=380, source_agent_id=2, target_agent_id=0, skill_id=0, health_percent=6.0 + ), + ], + ) + row = rows[0] + assert row.down_contribution_damage == 503 # 236 + 267 == EI value + assert row.against_downed_damage == 201 + + def test_against_downed_counts_a_landed_hit_that_dealt_no_damage() -> None: """The counter asks whether the hit landed, not whether it hurt. 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 36b367b2..8d083fb5 100644 --- a/libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py +++ b/libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py @@ -2445,6 +2445,24 @@ def scan_agent_awareness(source: BinaryIO | bytes) -> dict[int, tuple[int, int]] Records with ``time == 0`` are header-ish metadata rather than combat activity and are skipped, matching the parser's own down-duration scan. + + Two arcdps statechange kinds are excluded because they reference a + stale agent id after that agent has despawned, contaminating the + last-aware bound. On the corpus these are the only kinds that + produce phantom mentions of despawned combined-player raw ids: + + - ``HealthUpdate`` (byte 8): a heal on a minion whose raw id was + reused/merged can be logged against the *old* raw id long after + it despawned (druid healing instance 5342 at t=103267 while the + alive raw agent is 3534, not the dead 3515). + - ``StackActive`` (byte 27): a buff-stack activation tick on a + despawned minion's stale id (swiftness tick on 3514 at t=24012 + after 3514 despawned at 16799, protection tick on 4182 at + t=141972 after 4182 despawned at 129403). + + Without these exclusions every combined-player minion overcounts + boon uptime: the awareness bound runs past the despawn, so the buff + simulation accrues uptime that EI correctly truncates at despawn+10. """ data = _read_all(source) build_str = data[BUILD_OFFSET : BUILD_OFFSET + 8].decode("ascii", errors="replace") @@ -2452,6 +2470,9 @@ def scan_agent_awareness(source: BinaryIO | bytes) -> dict[int, tuple[int, int]] unpack = ( _EVENT_STRUCT_EVENTS_2025.unpack_from if is_evtc_2025 else _EVENT_STRUCT_EVENTS.unpack_from ) + # ponytail: is_statechange tuple index differs between the 2025 and legacy + # structs (16 vs 7). The exclusion set is the same either way. + _statechange_index = 16 if is_evtc_2025 else 7 cursor = _compute_post_skills_offset(data, is_evtc_2025=is_evtc_2025) end = len(data) @@ -2465,6 +2486,8 @@ def scan_agent_awareness(source: BinaryIO | bytes) -> dict[int, tuple[int, int]] continue if origin is None or time_ms < origin: origin = time_ms + if unpacked[_statechange_index] in _AWARENESS_EXCLUDED_STATECHANGES: + continue for agent_id in (src_agent, dst_agent): if not agent_id: continue @@ -2482,6 +2505,16 @@ def scan_agent_awareness(source: BinaryIO | bytes) -> dict[int, tuple[int, int]] } +#: arcdps statechange kinds excluded from :func:`scan_agent_awareness`. +#: See that function's docstring for the contamination rationale. +_AWARENESS_EXCLUDED_STATECHANGES: Final[frozenset[int]] = frozenset( + { + 8, # HealthUpdate — stale mentions of despawned merged-player raw ids + 27, # StackActive — buff-stack ticks on despawned minion stale ids + } +) + + #: arcdps buff id for Regeneration. The only buff Elite Insights routes #: through its regeneration-specific stacking logic. _REGENERATION_BUFF_ID: Final[int] = 718 diff --git a/libs/gw2_evtc_parser/tests/test_parser_awareness.py b/libs/gw2_evtc_parser/tests/test_parser_awareness.py index ebc04623..dacffe80 100644 --- a/libs/gw2_evtc_parser/tests/test_parser_awareness.py +++ b/libs/gw2_evtc_parser/tests/test_parser_awareness.py @@ -19,13 +19,16 @@ from gw2_evtc_parser import scan_agent_awareness _CBTEVENT_FMT = struct.Struct(" bytes: - """One 64-byte cbtevent carrying only a timestamp and the two actors.""" - return _CBTEVENT_FMT.pack( - time_ms, src_agent, dst_agent, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +def _event(time_ms: int, src_agent: int, dst_agent: int, is_statechange: int = 0) -> bytes: + """One 64-byte cbtevent carrying timestamp, actors, and statechange.""" + # is_statechange is tuple index 7 in the legacy unpack struct: + # (time, src, dst, val, buff_dmg, skill, is_nondamage, is_statechange, ...) + return _LEGACY_STRUCT.pack( + time_ms, src_agent, dst_agent, 0, 0, 0, 0, is_statechange, 0, 0, 0, 0 ) @@ -79,3 +82,30 @@ def test_scan_agent_awareness_ignores_absent_agent_ids() -> None: assert 0 not in awareness assert awareness == {10: (0, 0)} + + +def test_scan_agent_awareness_excludes_healthupdate_and_stackactive() -> None: + """HealthUpdate (8) and StackActive (27) carry stale agent mentions post-despawn.""" + raw = _evtc( + [ + _event(1_000, 10, 0, is_statechange=0), + _event(2_000, 10, 0, is_statechange=8), # HealthUpdate — skipped + _event(3_000, 10, 0, is_statechange=27), # StackActive — skipped + _event(4_000, 10, 0, is_statechange=0), + ] + ) + awareness = scan_agent_awareness(raw) + + # 2_000 and 3_000 events are skipped, so span runs 1_000 to 4_000 (relative: 0 to 3_000) + assert awareness[10] == (0, 3_000) + + # If all non-first events are skipped statechanges, the agent only stays aware at 1_000 + raw_only_skipped = _evtc( + [ + _event(1_000, 10, 0, is_statechange=0), + _event(5_000, 10, 0, is_statechange=8), + _event(6_000, 10, 0, is_statechange=27), + ] + ) + awareness_skipped = scan_agent_awareness(raw_only_skipped) + assert awareness_skipped[10] == (0, 0)