Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 60 additions & 13 deletions libs/gw2_analytics/src/gw2_analytics/buff_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
_CAPACITIES = {
"might": 25,
"stability": 25,
"regeneration": 5,
"regeneration": 15,
"stealth": 9,
"fury": 15,
"quickness": 9,
Expand All @@ -99,7 +99,7 @@
"aegis": 9,
"swiftness": 9,
"resistance": 9,
"resolution": 9,
"resolution": 15,
"superspeed": 9,
}
# All other boons default to 1 stack max (handled in compute logic).
Expand Down Expand Up @@ -275,16 +275,25 @@ def _advance_intensity(stack: _BuffStack, new_time_ms: int) -> None:
)
if next_expiry is None or next_expiry >= new_time_ms:
break
stack.cumulative_stack_ms += len(stack.expirations) * (next_expiry - stack.last_time_ms)
elapsed = next_expiry - stack.last_time_ms
stack.cumulative_stack_ms += len(stack.expirations) * elapsed
stack.last_time_ms = next_expiry
# EI: TotalDuration = Duration + Extensions; Duration decreases,
# so TotalDuration also decreases by the same elapsed amount.
if stack.total_durations:
stack.total_durations = [td - elapsed for td in stack.total_durations]
index = stack.expirations.index(next_expiry)
stack.expirations.pop(index)
if stack.total_durations:
stack.total_durations.pop(index)
stack.stack_ids.pop(index)
stack.healing_scores.pop(index)
stack.cumulative_stack_ms += len(stack.expirations) * (new_time_ms - stack.last_time_ms)
stack.last_time_ms = new_time_ms
elapsed = new_time_ms - stack.last_time_ms
if elapsed > 0:
stack.cumulative_stack_ms += len(stack.expirations) * elapsed
if stack.total_durations:
stack.total_durations = [td - elapsed for td in stack.total_durations]
stack.last_time_ms = new_time_ms

@staticmethod
def _advance(stack: _BuffStack, new_time_ms: int) -> None:
Expand Down Expand Up @@ -586,20 +595,58 @@ def process( # noqa: PLR0912, PLR0915
target_tracker.total_durations.pop(stack_index)
target_tracker.stack_ids.pop(stack_index)
target_tracker.healing_scores.pop(stack_index)
elif event.stacks and target_tracker.expirations:
index = next(
elif buff_name in _OVERRIDE_LOGIC_BUFFS and target_tracker.total_durations:
# OverrideLogic (might, stability): stack_id primary, then TotalDuration
# EI matches by abs(TotalDuration - removedDuration) < 15, iterating
# in order (sorted by TotalDuration asc). We preserve that order
# match as fallback after stack_id.
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, total_dur in enumerate(target_tracker.total_durations)
if abs(total_dur - event.duration_ms) < 15
),
None,
)
if stack_index is not None:
target_tracker.expirations.pop(stack_index)
target_tracker.total_durations.pop(stack_index)
target_tracker.stack_ids.pop(stack_index)
target_tracker.healing_scores.pop(stack_index)
elif target_tracker.expirations:
# Other intensity (stability): stack_id primary, then 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
),
0,
None,
)
target_tracker.expirations.pop(index)
if target_tracker.total_durations:
target_tracker.total_durations.pop(index)
target_tracker.stack_ids.pop(index)
target_tracker.healing_scores.pop(index)
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)
if target_tracker.total_durations:
target_tracker.total_durations.pop(stack_index)
target_tracker.stack_ids.pop(stack_index)
target_tracker.healing_scores.pop(stack_index)
elif event.kind == "remove_all":
target_tracker.expirations.clear()
target_tracker.total_durations.clear()
Expand Down
1 change: 1 addition & 0 deletions libs/gw2_analytics/src/gw2_analytics/ei_compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,7 @@ def player_agent_ids(agent: Agent, slice_lo: int = 0, slice_hi: int = 0) -> set[
agent.instance_id: agent.id for agent in fight.agents if agent.instance_id
},
squad_agent_ids={agent.id for agent in fight.agents if agent.is_player},
gw2_build=header.gw2_build if header else None,
)
has_downed_buff_applies = any(
isinstance(event, BoonApplyEvent) and event.kind == "apply" and event.skill_id == 770
Expand Down
63 changes: 57 additions & 6 deletions libs/gw2_analytics/src/gw2_analytics/rotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,7 @@ def build_skill_rotation( # noqa: PLR0912, PLR0915
agent_id_by_instance: Mapping[int, int] | None = None,
ownership_resolver: Callable[[int, int], int | None] | None = None,
squad_agent_ids: Collection[int] = (),
gw2_build: int | None = None,
) -> list[SkillCast]:
"""Return completed, clipped casts ordered by fight-relative start time.

Expand All @@ -619,6 +620,9 @@ def build_skill_rotation( # noqa: PLR0912, PLR0915
has_effect_data = any(isinstance(e, EffectEvent) for e in event_list)
origin = start_time_ms if start_time_ms is not None else min(e.time_ms for e in event_list)
event_times = [event.time_ms for event in event_list]
# GW2 build cutoff for Sand Cascade effect GUID -> skill ID change
# GW2 build 194363 (January 2026 patch) introduced effect GUID 44092AEF... and skill ID 43448
sand_cascade_gw2_build_cutoff = 194363
weaver_attunement_groups: dict[int, list[tuple[int, list[BoonApplyEvent | BuffApplyEvent]]]] = (
defaultdict(list)
)
Expand Down Expand Up @@ -951,15 +955,27 @@ 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 = spawn_owner_by_target.get(event.target_agent_id) or event.source_agent_id
owner = None
if ownership_resolver:
owner = ownership_resolver(event.target_agent_id, event.time_ms)
if owner is None:
owner = (
spawn_owner_by_target.get(event.target_agent_id) or event.source_agent_id
)
if owner:
add_instant(owner, 65418, event.time_ms)
if (
event.kind == "apply"
and event.skill_id == 59536
and event.target_agent_id in fern_hound_agent_ids
):
owner = spawn_owner_by_target.get(event.target_agent_id) or event.source_agent_id
owner = None
if ownership_resolver:
owner = ownership_resolver(event.target_agent_id, event.time_ms)
if owner is None:
owner = (
spawn_owner_by_target.get(event.target_agent_id) or event.source_agent_id
)
if owner:
add_instant(owner, 12717, event.time_ms)
if (
Expand All @@ -968,23 +984,41 @@ def add_instant(source: int, skill_id: int, time_ms: int, icd: int = 50) -> None
and event.duration_ms >= 1000
and event.target_agent_id in smokescale_agent_ids
):
owner = spawn_owner_by_target.get(event.target_agent_id) or event.source_agent_id
owner = None
if ownership_resolver:
owner = ownership_resolver(event.target_agent_id, event.time_ms)
if owner is None:
owner = (
spawn_owner_by_target.get(event.target_agent_id) or event.source_agent_id
)
if owner:
add_instant(owner, 31568, event.time_ms)
if (
event.kind == "apply"
and event.skill_id == 59536
and event.target_agent_id in warclaw_agent_ids
):
owner = spawn_owner_by_target.get(event.target_agent_id) or event.source_agent_id
owner = None
if ownership_resolver:
owner = ownership_resolver(event.target_agent_id, event.time_ms)
if owner is None:
owner = (
spawn_owner_by_target.get(event.target_agent_id) or event.source_agent_id
)
if owner:
add_instant(owner, 74314, event.time_ms)
if (
event.kind == "apply"
and event.skill_id == 59536
and event.target_agent_id in jungle_stalker_agent_ids
):
owner = spawn_owner_by_target.get(event.target_agent_id) or event.source_agent_id
owner = None
if ownership_resolver:
owner = ownership_resolver(event.target_agent_id, event.time_ms)
if owner is None:
owner = (
spawn_owner_by_target.get(event.target_agent_id) or event.source_agent_id
)
if owner:
add_instant(owner, 12658, event.time_ms)
# Spiteful Spirit (29560) - EI has two finders:
Expand Down Expand Up @@ -1028,7 +1062,12 @@ def add_instant(source: int, skill_id: int, time_ms: int, icd: int = 50) -> None
_MISSILE_ICD.get(event.skill_id, 50),
)
elif isinstance(event, SpawnEvent) and event.target_agent_id in ranger_pet_agent_ids:
add_instant(event.source_agent_id, -28, event.time_ms)
owner = None
if ownership_resolver:
owner = ownership_resolver(event.target_agent_id, event.time_ms)
if owner is None:
owner = event.source_agent_id
add_instant(owner, -28, event.time_ms)
elif isinstance(event, EffectEvent):
by_dst = event.guid in _EFFECT_CASTS_BY_DST
effect_skill_id: int | None
Expand Down Expand Up @@ -1127,6 +1166,18 @@ def add_instant(source: int, skill_id: int, time_ms: int, icd: int = 50) -> None
and other.target_agent_id == caster
}
effect_skill_id = 73116 if has_distress_buffs else None
elif event.guid == "23613E6E374EC6429FE9A69CC893984D":
# Sand Cascade: effect GUID unchanged but skill ID changed
# in GW2 build 197490
# Pre-197490: skill 62671; 197490+: skill 43448
is_post_197490 = (gw2_build or 0) >= sand_cascade_gw2_build_cutoff
effect_skill_id = 43448 if is_post_197490 else 62671
elif event.guid == "44092AEF6D619F4093FEA4E9D9142D01":
# Sand Cascade (post-GW2 build 197490 effect GUID):
# same skill ID logic
# Pre-197490: skill 62671; 197490+: skill 43448
is_post_197490 = (gw2_build or 0) >= sand_cascade_gw2_build_cutoff
effect_skill_id = 43448 if is_post_197490 else 62671
else:
effect_skill_id = (
_EFFECT_CASTS_BY_DST.get(event.guid)
Expand Down
36 changes: 32 additions & 4 deletions libs/gw2_analytics/tests/test_buff_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,13 @@ def test_remove_single_stacks(self) -> None:
_boon_apply(skill_id=might_id, target=1, time_ms=0, duration_ms=100000, stacks=5)
)
tracker.process(
_boon_apply(skill_id=might_id, target=1, time_ms=50000, kind="remove_single")
_boon_apply(
skill_id=might_id,
target=1,
time_ms=50000,
duration_ms=100000,
kind="remove_single",
)
)
uptimes = tracker.compute_player_uptimes(agent_id=1, duration_ms=100000)
# 5 stacks for 50s + 4 stacks for 50s averages 4.5 stacks.
Expand Down Expand Up @@ -491,8 +497,10 @@ def _regen_apply(time_ms: int, duration_ms: int, stack_id: int) -> BoonApplyEven


def _fill_regen_queue(tracker: BuffStateTracker) -> None:
"""Five stacks, longest last, so evicting the tail is expensive."""
for index, duration in enumerate((1_000, 1_000, 1_000, 1_000, 60_000)):
"""Fill queue to capacity (15), longest last, so evicting the tail is expensive."""
# 14 short stacks + 1 long stack = 15 total (capacity)
durations = [1_000] * 14 + [60_000]
for index, duration in enumerate(durations):
tracker.process(_regen_apply(0, duration, 100 + index))


Expand Down Expand Up @@ -521,9 +529,29 @@ def test_regeneration_overstack_hint_falls_back_to_the_closest_duration() -> Non

def test_regeneration_without_a_hint_still_evicts_the_tail() -> None:
"""A hint too far from the application is not one, and the tail goes."""
from gw2_analytics.buff_state import _capacity_for

# Temporarily reduce capacity for this test to match original test design (capacity 5)
original_capacity_for = _capacity_for

def test_capacity_for(name: str) -> int:
if name == "regeneration":
return 5
return original_capacity_for(name)

import gw2_analytics.buff_state as bs

bs._capacity_for = test_capacity_for

tracker = BuffStateTracker(regen_overstacks={7: [(5, 1_000, 102)]})
_fill_regen_queue(tracker)
# Fill to capacity (5): 4 short (1s) + 1 long (60s) = 5 stacks
for index, duration in enumerate([1_000] * 4 + [60_000]):
tracker.process(_regen_apply(0, duration, 100 + index))
# 6th application at 100ms: should overflow and evict tail (60s stack)
tracker.process(_regen_apply(100, 2_000, 999))

# The 60 s stack was evicted, so the queue runs dry long before 30 s.
assert tracker.compute_player_uptimes(7, 30_000)["regeneration"] < 30.0

# Restore
bs._capacity_for = original_capacity_for
1 change: 1 addition & 0 deletions libs/gw2_analytics/tests/test_rotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1445,6 +1445,7 @@ def test_post_july_sand_cascade_effect_is_deduplicated() -> None:
duration_ms=1_000,
start_time_ms=origin,
professions={7: Profession.NECROMANCER},
gw2_build=200000,
)
] == [(43448, 100)]

Expand Down
Loading