From d4836e3ea4edf8038a942c9b8ae7fba7d7d87835 Mon Sep 17 00:00:00 2001 From: Roland Salardon Date: Thu, 20 Aug 2026 12:02:11 +0200 Subject: [PATCH] fix(buff_state): QueueLogic overflow replaces in place + honor added_active EI QueueLogic.FindLowestValue replaces the shortest non-front stack in place (stacks[IndexOf(toRemove)] = toAdd), preserving queue order. The port popped the victim and appended at the end, rotating the queue and changing which stack fronts next. Align the mid-combat apply path, and honor added_active on both apply paths (EI BuffSimulator.Add calls _logic.Activate, moving the stack to the front). Signed-off-by: Roddy --- .../src/gw2_analytics/buff_state.py | 54 +++++++---- libs/gw2_analytics/tests/test_buff_state.py | 93 +++++++++++++++++++ 2 files changed, 131 insertions(+), 16 deletions(-) diff --git a/libs/gw2_analytics/src/gw2_analytics/buff_state.py b/libs/gw2_analytics/src/gw2_analytics/buff_state.py index 52fc1d39..7b187cd8 100644 --- a/libs/gw2_analytics/src/gw2_analytics/buff_state.py +++ b/libs/gw2_analytics/src/gw2_analytics/buff_state.py @@ -412,13 +412,14 @@ def process( # noqa: PLR0912, PLR0915 # 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). + # Add new stack; on overflow EI FindLowestValue replaces the + # shortest non-front stack IN PLACE (stacks[IndexOf(toRemove)] + # = toAdd), preserving queue order -- not pop+append, which + # would rotate the queue and change which stack fronts next. 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 + # Find shortest among non-front stacks (index >= 1) min_idx = 1 min_dur = target_tracker.expirations[1] for i in range(2, len(target_tracker.expirations)): @@ -426,19 +427,33 @@ def process( # noqa: PLR0912, PLR0915 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) + target_tracker.expirations[min_idx] = duration + target_tracker.stack_ids[min_idx] = event.stack_id + target_tracker.healing_scores[min_idx] = self._healing_by_agent.get( + event.source_agent_id, 0 + ) 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) - ) + # Only one stack (the front), replace it + target_tracker.expirations[0] = duration + target_tracker.stack_ids[0] = event.stack_id + target_tracker.healing_scores[0] = self._healing_by_agent.get( + event.source_agent_id, 0 + ) + else: + 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) + ) + if event.added_active and event.stack_id in target_tracker.stack_ids: + # EI BuffSimulator.Add calls _logic.Activate on addedActive, + # which moves the new stack to the front of the queue. + 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) + ) elif _max_stacks_for(buff_name) > 1: if buff_name in _OVERRIDE_LOGIC_BUFFS: # OverrideLogic (EI): sort by TotalDuration (shortest first), @@ -709,6 +724,13 @@ def _process_buff_apply(self, event: BuffApplyEvent) -> None: # noqa: PLR0912, target_tracker.expirations.append(duration) target_tracker.stack_ids.append(event.stack_id) target_tracker.healing_scores.append(0) + if event.added_active and event.stack_id in target_tracker.stack_ids: + # EI BuffApplyEvent.UpdateSimulator passes addedActive -> + # BuffSimulator.Add -> _logic.Activate: move to front. + 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)) # Intensity buffs with max_stacks > 1 (might, stability) elif _max_stacks_for(buff_name) > 1: diff --git a/libs/gw2_analytics/tests/test_buff_state.py b/libs/gw2_analytics/tests/test_buff_state.py index 8b5cc795..8bd4bbb7 100644 --- a/libs/gw2_analytics/tests/test_buff_state.py +++ b/libs/gw2_analytics/tests/test_buff_state.py @@ -555,3 +555,96 @@ def test_capacity_for(name: str) -> int: # Restore bs._capacity_for = original_capacity_for + + +def test_queue_logic_overflow_replaces_shortest_stack_in_place() -> None: + """EI QueueLogic.FindLowestValue replaces the shortest non-front stack + in place (stacks[IndexOf(toRemove)] = toAdd), preserving queue order. + + The old port popped the victim and appended the new stack at the end, + rotating the queue and changing which stack fronts next. + """ + protection_id = TRACKED_BUFFS["protection"] + tracker = BuffStateTracker() + # Fill to capacity (15) with durations so the shortest non-front stack + # (200 ms) sits at index 5, not at the tail. + durations = [5_000, 1_000, 900, 300, 400, 200, 700, 800, 600, 550, 650, 750, 850, 950, 999] + for index, duration in enumerate(durations): + tracker.process( + BoonApplyEvent( + time_ms=0, + source_agent_id=1, + target_agent_id=7, + skill_id=protection_id, + duration_ms=duration, + stacks=1, + stack_id=100 + index, + ) + ) + stack = tracker._agent_buffs[7]["protection"] + assert len(stack.expirations) == 15 + + # Overflow apply: EI replaces the 200 ms stack at index 5 in place. + tracker.process( + BoonApplyEvent( + time_ms=0, + source_agent_id=1, + target_agent_id=7, + skill_id=protection_id, + duration_ms=9_999, + stacks=1, + stack_id=999, + ) + ) + assert len(stack.expirations) == 15 + assert stack.expirations[5] == 9_999 # replaced in place, not appended + assert stack.expirations[0] == 5_000 # front untouched + assert stack.expirations[6] == 700 # tail order preserved + + +def test_queue_logic_added_active_moves_stack_to_front() -> None: + """EI BuffSimulator.Add calls _logic.Activate on addedActive, which moves + the new stack to the front of the queue. + """ + protection_id = TRACKED_BUFFS["protection"] + tracker = BuffStateTracker() + tracker.process( + BoonApplyEvent( + time_ms=0, + source_agent_id=1, + target_agent_id=7, + skill_id=protection_id, + duration_ms=5_000, + stacks=1, + stack_id=11, + ) + ) + tracker.process( + BoonApplyEvent( + time_ms=0, + source_agent_id=1, + target_agent_id=7, + skill_id=protection_id, + duration_ms=8_000, + stacks=1, + stack_id=22, + ) + ) + stack = tracker._agent_buffs[7]["protection"] + assert stack.expirations == [5_000, 8_000] + + # The new stack is flagged active -> it fronts the queue immediately. + tracker.process( + BoonApplyEvent( + time_ms=0, + source_agent_id=1, + target_agent_id=7, + skill_id=protection_id, + duration_ms=3_000, + stacks=1, + stack_id=33, + added_active=True, + ) + ) + assert stack.stack_ids == [33, 11, 22] + assert stack.expirations == [3_000, 5_000, 8_000]