diff --git a/libs/gw2_analytics/src/gw2_analytics/buff_state.py b/libs/gw2_analytics/src/gw2_analytics/buff_state.py index 586a95f2..a0f99496 100644 --- a/libs/gw2_analytics/src/gw2_analytics/buff_state.py +++ b/libs/gw2_analytics/src/gw2_analytics/buff_state.py @@ -28,7 +28,6 @@ from __future__ import annotations -import math from collections import defaultdict from pydantic import BaseModel, ConfigDict @@ -67,18 +66,41 @@ #: drop shortest on overflow, graft extensions by closest TotalDuration). _OVERRIDE_LOGIC_BUFFS: frozenset[str] = frozenset({"might", "stability"}) +#: Single-stack duration boons that use Elite Insights' QueueLogic (capacity 9, +#: only front stack counts toward uptime, drop shortest on overflow, +#: graft extensions by closest duration, added_active moves to front). +#: Regeneration uses its own special queue logic; might/stability use OverrideLogic. +_QUEUE_LOGIC_BUFFS: frozenset[str] = frozenset( + { + "fury", + "quickness", + "alacrity", + "protection", + "vigor", + "aegis", + "swiftness", + "resistance", + "resolution", + "superspeed", + "stealth", + } +) + _CAPACITIES = { "might": 25, "stability": 25, "regeneration": 5, - "stealth": 5, - "fury": 99, - "quickness": 99, - "protection": 99, - "vigor": 99, - "swiftness": 99, - "resistance": 99, - "resolution": 99, + "stealth": 9, + "fury": 9, + "quickness": 9, + "alacrity": 9, + "protection": 9, + "vigor": 9, + "aegis": 9, + "swiftness": 9, + "resistance": 9, + "resolution": 9, + "superspeed": 9, } # All other boons default to 1 stack max (handled in compute logic). @@ -215,29 +237,37 @@ def _get_stack(self, agent_id: int, buff_name: str) -> _BuffStack: return agent[buff_name] @staticmethod - def _advance(stack: _BuffStack, new_time_ms: int) -> None: - """Accumulate stack-time through expirations up to ``new_time_ms``.""" - if _max_stacks_for(stack.name) == 1: - elapsed = new_time_ms - stack.last_time_ms - while elapsed > 0 and stack.expirations: - remaining = stack.expirations[0] - if remaining is None: - stack.cumulative_stack_ms += elapsed - break - active = min(elapsed, remaining) - stack.cumulative_stack_ms += active - elapsed -= active - remaining -= active - if remaining == 0: - stack.expirations.pop(0) - if stack.total_durations: - stack.total_durations.pop(0) - stack.stack_ids.pop(0) - stack.healing_scores.pop(0) - else: - stack.expirations[0] = remaining - stack.last_time_ms = new_time_ms - return + def _advance_single(stack: _BuffStack, new_time_ms: int) -> None: + """Accumulate stack-time for single-stack (max_stacks=1) through expirations.""" + elapsed = new_time_ms - stack.last_time_ms + while elapsed > 0 and stack.expirations: + remaining = stack.expirations[0] + if remaining is None: + stack.cumulative_stack_ms += elapsed + break + active = min(elapsed, remaining) + stack.cumulative_stack_ms += active + elapsed -= active + remaining -= active + if remaining == 0: + stack.expirations.pop(0) + if stack.total_durations: + stack.total_durations.pop(0) + stack.stack_ids.pop(0) + stack.healing_scores.pop(0) + else: + stack.expirations[0] = remaining + stack.last_time_ms = new_time_ms + + @staticmethod + def _advance_queue(stack: _BuffStack, new_time_ms: int) -> None: + """Accumulate stack-time for QueueLogic (only front stack counts).""" + # QueueLogic uses the same front-stack consumption logic as single-stack + BuffStateTracker._advance_single(stack, new_time_ms) + + @staticmethod + def _advance_intensity(stack: _BuffStack, new_time_ms: int) -> None: + """Accumulate stack-time for intensity buffs (max_stacks > 1).""" while True: next_expiry = min( (expiry for expiry in stack.expirations if expiry is not None), @@ -256,6 +286,17 @@ def _advance(stack: _BuffStack, new_time_ms: int) -> None: stack.cumulative_stack_ms += len(stack.expirations) * (new_time_ms - stack.last_time_ms) stack.last_time_ms = new_time_ms + @staticmethod + def _advance(stack: _BuffStack, new_time_ms: int) -> None: + """Accumulate stack-time through expirations up to ``new_time_ms``.""" + if _max_stacks_for(stack.name) == 1: + if stack.name in _QUEUE_LOGIC_BUFFS: + BuffStateTracker._advance_queue(stack, new_time_ms) + else: + BuffStateTracker._advance_single(stack, new_time_ms) + return + BuffStateTracker._advance_intensity(stack, new_time_ms) + def _regen_overstack_hint(self, agent_id: int, time_ms: int) -> tuple[int, int] | None: """The displaced-stack hint arcdps recorded just before this apply.""" hints = self._regen_overstacks.get(agent_id) @@ -358,7 +399,38 @@ def process( # noqa: PLR0912, PLR0915 self._advance(target_tracker, time_ms) if event.kind == "apply": - if _max_stacks_for(buff_name) > 1: + # QueueLogic buffs (single-stack duration boons with queue behavior) + # are handled separately even though they have max_stacks=1. + if buff_name in _QUEUE_LOGIC_BUFFS: + # QueueLogic (EI): capacity 9, only front stack counts. + # Add new stack to end; if at capacity, drop shortest (not front). + duration = event.duration_ms if event.duration_ms > 0 else None + if len(target_tracker.expirations) >= _capacity_for(buff_name): + # Find shortest duration (excluding front which is active) + # EI drops the shortest duration stack, not the front + if len(target_tracker.expirations) > 1: + # Find shortest among non-front stacks + min_idx = 1 + min_dur = target_tracker.expirations[1] + for i in range(2, len(target_tracker.expirations)): + dur = target_tracker.expirations[i] + if dur is not None and (min_dur is None or dur < min_dur): + min_dur = dur + min_idx = i + target_tracker.expirations.pop(min_idx) + target_tracker.stack_ids.pop(min_idx) + target_tracker.healing_scores.pop(min_idx) + else: + # Only one stack, replace it + target_tracker.expirations.pop(0) + target_tracker.stack_ids.pop(0) + target_tracker.healing_scores.pop(0) + target_tracker.expirations.append(duration) + target_tracker.stack_ids.append(event.stack_id) + target_tracker.healing_scores.append( + self._healing_by_agent.get(event.source_agent_id, 0) + ) + elif _max_stacks_for(buff_name) > 1: if buff_name in _OVERRIDE_LOGIC_BUFFS: # OverrideLogic (EI): sort by TotalDuration (shortest first), # remove index 0 when at capacity. @@ -467,7 +539,30 @@ def process( # noqa: PLR0912, PLR0915 del target_tracker.stack_ids[_capacity_for(buff_name) :] del target_tracker.healing_scores[_capacity_for(buff_name) :] elif event.kind == "remove_single": - if _max_stacks_for(buff_name) == 1 and target_tracker.expirations: + if buff_name in _QUEUE_LOGIC_BUFFS and target_tracker.expirations: + # QueueLogic: find stack by stack_id or closest duration + stack_index = next( + ( + i + for i, stack_id in enumerate(target_tracker.stack_ids) + if event.stack_id is not None and stack_id == event.stack_id + ), + None, + ) + if stack_index is None: + stack_index = next( + ( + i + for i, duration in enumerate(target_tracker.expirations) + if duration is not None and abs(duration - event.duration_ms) < 15 + ), + None, + ) + if stack_index is not None: + target_tracker.expirations.pop(stack_index) + target_tracker.stack_ids.pop(stack_index) + target_tracker.healing_scores.pop(stack_index) + elif _max_stacks_for(buff_name) == 1 and target_tracker.expirations: stack_index = next( ( i @@ -517,7 +612,7 @@ def process( # noqa: PLR0912, PLR0915 event.duration_ms * event.stacks ) - def _process_buff_apply(self, event: BuffApplyEvent) -> None: + def _process_buff_apply(self, event: BuffApplyEvent) -> None: # noqa: PLR0915 """Process a ``BuffApplyEvent`` (CBTS_BUFFAPPLY statechange). These are initial-stack snapshots: ``skill_id`` is the buff ID, @@ -536,7 +631,23 @@ def _process_buff_apply(self, event: BuffApplyEvent) -> None: time_ms = self._relative_time(event.time_ms) self._advance(target_tracker, time_ms) expiry = time_ms + event.duration_ms if event.duration_ms > 0 else None - if _max_stacks_for(buff_name) > 1: + # QueueLogic buffs (single-stack duration boons with queue behavior) + if buff_name in _QUEUE_LOGIC_BUFFS: + # QueueLogic: add initial stacks as queue + duration = event.duration_ms if event.duration_ms > 0 else None + target_tracker.expirations.extend([duration] * event.stacks) + target_tracker.stack_ids.extend([event.stack_id] * event.stacks) + target_tracker.healing_scores.extend([0] * event.stacks) + # No total_durations for QueueLogic buffs + # Keep only up to capacity (drop oldest if over capacity) + if len(target_tracker.expirations) > _capacity_for(buff_name): + excess = len(target_tracker.expirations) - _capacity_for(buff_name) + target_tracker.expirations = target_tracker.expirations[excess:] + target_tracker.stack_ids = target_tracker.stack_ids[excess:] + target_tracker.healing_scores = target_tracker.healing_scores[excess:] + + # Intensity buffs with max_stacks > 1 (might, stability) + elif _max_stacks_for(buff_name) > 1: if buff_name in _OVERRIDE_LOGIC_BUFFS: # OverrideLogic: track TotalDuration for each stack total_dur = event.duration_ms @@ -561,23 +672,26 @@ def _process_buff_apply(self, event: BuffApplyEvent) -> None: target_tracker.stack_ids = [sid for _, _, sid, _ in pairs] target_tracker.healing_scores = [h for _, _, _, h in pairs] else: + # Other intensity buffs (stability): sort by expiration target_tracker.expirations.extend([expiry] * event.stacks) target_tracker.stack_ids.extend([event.stack_id] * event.stacks) - target_tracker.healing_scores.extend([0] * event.stacks) - pairs_3: list[tuple[int | None, int, int]] = sorted( + target_tracker.healing_scores.extend( + [self._healing_by_agent.get(event.source_agent_id, 0)] * event.stacks + ) + stability_pairs = sorted( zip( target_tracker.expirations, target_tracker.stack_ids, target_tracker.healing_scores, strict=True, - ), - key=lambda pair: pair[0] if pair[0] is not None else math.inf, - )[-_capacity_for(buff_name) :] - target_tracker.expirations = [value for value, _, _ in pairs_3] - target_tracker.stack_ids = [stack_id for _, stack_id, _ in pairs_3] - target_tracker.healing_scores = [healing for _, _, healing in pairs_3] - if target_tracker.total_durations: - target_tracker.total_durations.clear() + ) + ) + stability_pairs = stability_pairs[-_capacity_for(buff_name) :] + target_tracker.expirations = [expiry for expiry, _, _ in stability_pairs] + target_tracker.stack_ids = [stack_id for _, stack_id, _ in stability_pairs] + target_tracker.healing_scores = [healing for _, _, healing in stability_pairs] + + # Single-stack duration boons (fury, quickness, etc.) - original behavior else: target_tracker.expirations.append(event.duration_ms or None) target_tracker.stack_ids.append(event.stack_id) @@ -611,6 +725,13 @@ def _process_buff_extension(self, event: BuffExtensionEvent) -> None: target_tracker.expirations[index] or 0 ) + event.extended_duration_ms return + if buff_name in _QUEUE_LOGIC_BUFFS and target_tracker.expirations: + # QueueLogic: graft extension onto stack with duration closest to old_duration + 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 if target_tracker.expirations and ( old_duration > 0 or len(target_tracker.expirations) >= _capacity_for(buff_name) ):