diff --git a/libs/gw2_analytics/src/gw2_analytics/ei_compare.py b/libs/gw2_analytics/src/gw2_analytics/ei_compare.py index 8d578aa6..6f61c03f 100644 --- a/libs/gw2_analytics/src/gw2_analytics/ei_compare.py +++ b/libs/gw2_analytics/src/gw2_analytics/ei_compare.py @@ -223,14 +223,19 @@ 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)] + 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)] + up_events = [event for event in event_list if isinstance(event, UpEvent)] + outcome_events = [event for event in event_list if isinstance(event, CombatOutcomeEvent)] down_rows = DownContributionAggregator().aggregate( actor_damage_events, - [event for event in event_list if isinstance(event, DownEvent)], - [event for event in event_list if isinstance(event, DeathEvent)], + down_events, + death_events, duration_ms / 1000, - health_events=[event for event in event_list if isinstance(event, HealthUpdateEvent)], - up_events=[event for event in event_list if isinstance(event, UpEvent)], - outcome_events=[event for event in event_list if isinstance(event, CombatOutcomeEvent)], + health_events=health_events, + up_events=up_events, + outcome_events=outcome_events, cc_events=cc_events, ) down_by_source = {row.source_agent_id: row for row in down_rows} @@ -245,6 +250,41 @@ def compare_elite_insights( # noqa: PLR0912, PLR0915 for fight_agent in fight.agents: if fight_agent.instance_id: agent_ids_by_instance[fight_agent.instance_id].add(fight_agent.id) + down_events_by_source: dict[int, list[DownEvent]] = defaultdict(list) + death_events_by_source: dict[int, list[DeathEvent]] = defaultdict(list) + health_events_by_source: dict[int, list[HealthUpdateEvent]] = defaultdict(list) + up_events_by_source: dict[int, list[UpEvent]] = defaultdict(list) + outcome_events_by_target: dict[int, list[CombatOutcomeEvent]] = defaultdict(list) + block_count_by_source: dict[int, int] = defaultdict(int) + dodge_count_by_source: dict[int, int] = defaultdict(int) + downed_buff_times_by_target: dict[int, set[int]] = defaultdict(set) + for down_event in down_events: + down_events_by_source[down_event.source_agent_id].append(down_event) + for death_event in death_events: + death_events_by_source[death_event.source_agent_id].append(death_event) + for health_event in health_events: + health_events_by_source[health_event.source_agent_id].append(health_event) + for up_event in up_events: + up_events_by_source[up_event.source_agent_id].append(up_event) + for outcome_event in outcome_events: + outcome_events_by_target[outcome_event.target_agent_id].append(outcome_event) + for indexed_event in event_list: + if isinstance(indexed_event, BlockEvent): + block_count_by_source[indexed_event.source_agent_id] += 1 + elif isinstance(indexed_event, DodgeEvent): + dodge_count_by_source[indexed_event.source_agent_id] += 1 + elif ( + isinstance(indexed_event, BoonApplyEvent) + and indexed_event.kind == "apply" + and indexed_event.skill_id == 770 + ): + downed_buff_times_by_target[indexed_event.target_agent_id].add(indexed_event.time_ms) + + def indexed_events(index: dict[int, list[Any]], agent_ids: set[int]) -> list[Any]: + return [ + indexed_event for agent_id in agent_ids for indexed_event in index.get(agent_id, []) + ] + targets = expected.get("targets") if isinstance(expected.get("targets"), list) else [] buff_map = expected.get("buffMap") condition_skill_ids: set[int] | None = ( @@ -271,11 +311,11 @@ def compare_elite_insights( # noqa: PLR0912, PLR0915 start_time_ms=origin, healing_by_agent={agent.id: agent.healing for agent in fight.agents}, ) - for event in event_list: - if isinstance(event, (BoonApplyEvent, BuffApplyEvent)): - tracker.process(event) - elif isinstance(event, DespawnEvent): - tracker.end_agent(event.source_agent_id, event.time_ms + 10) + for tracked_event in event_list: + if isinstance(tracked_event, (BoonApplyEvent, BuffApplyEvent)): + tracker.process(tracked_event) + elif isinstance(tracked_event, DespawnEvent): + tracker.end_agent(tracked_event.source_agent_id, tracked_event.time_ms + 10) rotation = build_skill_rotation( event_list, duration_ms, @@ -361,17 +401,11 @@ def compare_elite_insights( # noqa: PLR0912, PLR0915 defenses = player.get("defenses") if isinstance(defenses, list) and defenses and isinstance(defenses[0], dict): taken = [event for event in damage_events if event.target_agent_id in agent_ids] - downed = [ - event - for event in event_list - if isinstance(event, DownEvent) and event.source_agent_id in agent_ids - ] + downed = indexed_events(down_events_by_source, agent_ids) outcome_downs = { event.time_ms - for event in event_list - if isinstance(event, CombatOutcomeEvent) - and event.outcome == "downed" - and event.target_agent_id in agent_ids + for event in indexed_events(outcome_events_by_target, agent_ids) + if event.outcome == "downed" } defense_values = { "damageTaken": sum(event.damage for event in taken), @@ -382,26 +416,15 @@ def compare_elite_insights( # noqa: PLR0912, PLR0915 "powerDamageTaken": sum( event.damage - _condition_damage(event, condition_skill_ids) for event in taken ), - "blockedCount": sum( - isinstance(event, BlockEvent) and event.source_agent_id == agent.id - for event in event_list - ), - "evadedCount": sum( - isinstance(event, DodgeEvent) and event.source_agent_id == agent.id - for event in event_list - ), + "blockedCount": block_count_by_source[agent.id], + "evadedCount": dodge_count_by_source[agent.id], "downCount": ( len(outcome_downs) if outcome_downs and not anonymous else len( - { - event.time_ms - for event in event_list - if isinstance(event, BoonApplyEvent) - and event.kind == "apply" - and event.skill_id == 770 - and event.target_agent_id in agent_ids - } + set().union( + *(downed_buff_times_by_target[agent_id] for agent_id in agent_ids) + ) ) ) or ( @@ -412,10 +435,7 @@ def compare_elite_insights( # noqa: PLR0912, PLR0915 else 0 ), "downDuration": sum(event.downtime_ms for event in downed), - "deadCount": sum( - isinstance(event, DeathEvent) and event.source_agent_id in agent_ids - for event in event_list - ), + "deadCount": len(indexed_events(death_events_by_source, agent_ids)), } values["defenses"] = defense_values _compare_fields(f"{prefix}.defenses", defenses[0], defense_values, differences) @@ -556,34 +576,12 @@ def compare_elite_insights( # noqa: PLR0912, PLR0915 target_cc = [event for event in source_cc if event.target_agent_id in target_ids] target_down_rows = DownContributionAggregator().aggregate( actor_target_damage, - [ - event - for event in event_list - if isinstance(event, DownEvent) and event.source_agent_id in target_ids - ], - [ - event - for event in event_list - if isinstance(event, DeathEvent) and event.source_agent_id in target_ids - ], + indexed_events(down_events_by_source, target_ids), + indexed_events(death_events_by_source, target_ids), duration_ms / 1000, - health_events=[ - event - for event in event_list - if isinstance(event, HealthUpdateEvent) - and event.source_agent_id in target_ids - ], - up_events=[ - event - for event in event_list - if isinstance(event, UpEvent) and event.source_agent_id in target_ids - ], - outcome_events=[ - event - for event in event_list - if isinstance(event, CombatOutcomeEvent) - and event.target_agent_id in target_ids - ], + health_events=indexed_events(health_events_by_source, target_ids), + up_events=indexed_events(up_events_by_source, target_ids), + outcome_events=indexed_events(outcome_events_by_target, target_ids), cc_events=target_cc, ) target_down_by_source = {row.source_agent_id: row for row in target_down_rows} diff --git a/libs/gw2_analytics/src/gw2_analytics/rotation.py b/libs/gw2_analytics/src/gw2_analytics/rotation.py index dee87248..5f3cc8c9 100644 --- a/libs/gw2_analytics/src/gw2_analytics/rotation.py +++ b/libs/gw2_analytics/src/gw2_analytics/rotation.py @@ -2,6 +2,8 @@ from __future__ import annotations +from bisect import bisect_left, bisect_right +from collections import defaultdict from collections.abc import Collection, Iterable from pydantic import BaseModel, ConfigDict @@ -226,12 +228,55 @@ def build_skill_rotation( # noqa: PLR0912, PLR0915 if not event_list: return [] origin = start_time_ms if start_time_ms is not None else min(e.time_ms for e in event_list) - swaps = [e for e in event_list if isinstance(e, WeaponSwapEvent)] + event_times = [event.time_ms for event in event_list] + swaps_by_agent: dict[int, list[WeaponSwapEvent]] = defaultdict(list) + activations_by_agent: dict[int, list[SkillActivationEvent]] = defaultdict(list) + spawn_owner_by_target: dict[int, int] = {} + for indexed_event in event_list: + if isinstance(indexed_event, WeaponSwapEvent): + swaps_by_agent[indexed_event.source_agent_id].append(indexed_event) + elif isinstance(indexed_event, SkillActivationEvent): + activations_by_agent[indexed_event.source_agent_id].append(indexed_event) + elif isinstance(indexed_event, SpawnEvent): + spawn_owner_by_target.setdefault( + indexed_event.target_agent_id, + indexed_event.source_agent_id, + ) + swap_times_by_agent = { + agent_id: [swap.time_ms for swap in agent_swaps] + for agent_id, agent_swaps in swaps_by_agent.items() + } active: dict[tuple[int, int], SkillActivationEvent] = {} casts: list[SkillCast] = [] last_instant: dict[tuple[int, int], int] = {} active_buff_until: dict[tuple[int, int], int] = {} + def nearby_events(time_ms: int, radius_ms: int) -> list[Event]: + return event_list[ + bisect_left(event_times, time_ms - radius_ms) : bisect_right( + event_times, + time_ms + radius_ms, + ) + ] + + def nearby_swap(agent_id: int, time_ms: int, radius_ms: int = 5) -> WeaponSwapEvent | None: + agent_swaps = swaps_by_agent.get(agent_id, []) + agent_swap_times = swap_times_by_agent.get(agent_id, []) + for swap in agent_swaps[ + bisect_left(agent_swap_times, time_ms - radius_ms) : bisect_right( + agent_swap_times, + time_ms + radius_ms, + ) + ]: + if abs(swap.time_ms - time_ms) < radius_ms: + return swap + return None + + def next_swap_time_after(agent_id: int, time_ms: int) -> int: + agent_swap_times = swap_times_by_agent.get(agent_id, []) + index = bisect_right(agent_swap_times, time_ms + 10) + return agent_swap_times[index] if index < len(agent_swap_times) else 1 << 63 + def add_instant(source: int, skill_id: int, time_ms: int, icd: int = 50) -> None: key = (source, skill_id) if time_ms - last_instant.get(key, -(1 << 63)) >= icd: @@ -272,23 +317,19 @@ def add_instant(source: int, skill_id: int, time_ms: int, icd: int = 50) -> None ) ) elif isinstance(event, WeaponSwapEvent): - next_swap_time = next( - ( - swap.time_ms - for swap in swaps - if swap.source_agent_id == event.source_agent_id - and swap.time_ms > event.time_ms + 10 - ), - 1 << 63, - ) + next_swap_time = next_swap_time_after(event.source_agent_id, event.time_ms) + agent_activations = activations_by_agent.get(event.source_agent_id, []) + activation_times = [activation.time_ms for activation in agent_activations] for kit_skill, bundle_skills in _ENGINEER_KIT_BUNDLES.items(): if ( sum( - isinstance(other, SkillActivationEvent) - and other.source_agent_id == event.source_agent_id - and event.time_ms + 10 <= other.time_ms < next_swap_time - and other.skill_id in bundle_skills - for other in event_list + other.skill_id in bundle_skills + for other in agent_activations[ + bisect_left(activation_times, event.time_ms + 10) : bisect_left( + activation_times, + next_swap_time, + ) + ] ) >= 2 ): @@ -309,11 +350,7 @@ def add_instant(source: int, skill_id: int, time_ms: int, icd: int = 50) -> None if instant is not None: skill_id, before_swap = instant time_ms = event.time_ms - if before_swap and any( - swap.source_agent_id == event.target_agent_id - and abs(swap.time_ms - event.time_ms) < 5 - for swap in swaps - ): + if before_swap and nearby_swap(event.target_agent_id, event.time_ms): time_ms -= 1 add_instant(event.target_agent_id, skill_id, time_ms) mapped = ( @@ -328,19 +365,11 @@ def add_instant(source: int, skill_id: int, time_ms: int, icd: int = 50) -> None mapped = None if mapped is not None: mapped_time = event.time_ms - nearby_swap = next( - ( - swap - for swap in swaps - if swap.source_agent_id == event.target_agent_id - and abs(swap.time_ms - event.time_ms) < 5 - ), - None, - ) - if nearby_swap is not None and event.skill_id in _BEFORE_SWAP_BUFFS: - mapped_time = nearby_swap.time_ms - 1 - elif nearby_swap is not None and event.skill_id in _AFTER_SWAP_BUFFS: - mapped_time = max(mapped_time, nearby_swap.time_ms + 1) + swap = nearby_swap(event.target_agent_id, event.time_ms) + if swap is not None and event.skill_id in _BEFORE_SWAP_BUFFS: + mapped_time = swap.time_ms - 1 + elif swap is not None and event.skill_id in _AFTER_SWAP_BUFFS: + mapped_time = max(mapped_time, swap.time_ms + 1) add_instant(event.target_agent_id, mapped, mapped_time) if event.kind == "apply": active_buff_until[buff_key] = max( @@ -356,15 +385,7 @@ def add_instant(source: int, skill_id: int, time_ms: int, icd: int = 50) -> None and event.skill_id == 59536 and event.target_agent_id in siege_turtle_agent_ids ): - owner = next( - ( - spawn.source_agent_id - for spawn in event_list - if isinstance(spawn, SpawnEvent) - and spawn.target_agent_id == event.target_agent_id - ), - 0, - ) + owner = spawn_owner_by_target.get(event.target_agent_id, 0) if owner: add_instant(owner, 65418, event.time_ms) elif isinstance(event, DamageEvent) and event.skill_id in _DAMAGE_CASTS: @@ -394,7 +415,7 @@ def add_instant(source: int, skill_id: int, time_ms: int, icd: int = 50) -> None and other.source_agent_id == caster and other.target_agent_id in clone_agent_ids and abs(other.time_ms - event.time_ms) < 30 - for other in event_list + for other in nearby_events(event.time_ms, 29) ) else -27 ) @@ -409,7 +430,7 @@ def add_instant(source: int, skill_id: int, time_ms: int, icd: int = 50) -> None and other.skill_id == 10243 and other.target_agent_id == caster and abs(other.time_ms - event.time_ms) < 10 - for other in event_list + for other in nearby_events(event.time_ms, 9) ) else 10191 if caster in mesmer_agent_ids @@ -428,11 +449,7 @@ def add_instant(source: int, skill_id: int, time_ms: int, icd: int = 50) -> None continue needs_related = by_dst or event.guid in _SECONDARY_EFFECTS needs_related = needs_related or event.guid in _MESMER_SHATTER_EFFECTS - related = ( - [other for other in event_list if abs(other.time_ms - event.time_ms) < 10] - if needs_related - else [] - ) + related = nearby_events(event.time_ms, 9) if needs_related else [] if ( event.guid == "122BA55CCDF2B643929F6C4A97226DC9" and sum( @@ -472,7 +489,7 @@ def add_instant(source: int, skill_id: int, time_ms: int, icd: int = 50) -> None and other.source_agent_id == caster and other.skill_id == 38767 and abs(other.time_ms - event.time_ms) < 50 - for other in event_list + for other in nearby_events(event.time_ms, 49) ): add_instant(caster, effect_skill_id, event.time_ms)