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
133 changes: 103 additions & 30 deletions libs/gw2_analytics/src/gw2_analytics/buff_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,17 +89,17 @@
_CAPACITIES = {
"might": 25,
"stability": 25,
"regeneration": 5,
"regeneration": 15,
"stealth": 9,
"fury": 15,
"quickness": 9,
"quickness": 15,
"alacrity": 9,
"protection": 9,
"protection": 15,
"vigor": 9,
"aegis": 9,
"swiftness": 9,
"swiftness": 15,
"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 All @@ -612,7 +659,7 @@ def process( # noqa: PLR0912, PLR0915
event.duration_ms * event.stacks
)

def _process_buff_apply(self, event: BuffApplyEvent) -> None: # noqa: PLR0915
def _process_buff_apply(self, event: BuffApplyEvent) -> None: # noqa: PLR0912, PLR0915
"""Process a ``BuffApplyEvent`` (CBTS_BUFFAPPLY statechange).

These are initial-stack snapshots: ``skill_id`` is the buff ID,
Expand All @@ -633,18 +680,35 @@ def _process_buff_apply(self, event: BuffApplyEvent) -> None: # noqa: PLR0915
expiry = time_ms + event.duration_ms if event.duration_ms > 0 else None
# 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:]
# QueueLogic: add initial stacks one by one, using same overflow
# logic as mid-combat applies (drop shortest non-front on overflow,
# replacing it in place to preserve queue order).
capacity = _capacity_for(buff_name)
for _ in range(event.stacks):
duration = event.duration_ms if event.duration_ms > 0 else None
if len(target_tracker.expirations) >= capacity:
# Find shortest duration among non-front stacks (index >= 1)
if len(target_tracker.expirations) > 1:
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
# Replace in place (EI behavior), not append
target_tracker.expirations[min_idx] = duration
target_tracker.stack_ids[min_idx] = event.stack_id
target_tracker.healing_scores[min_idx] = 0
else:
# 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] = 0
else:
target_tracker.expirations.append(duration)
target_tracker.stack_ids.append(event.stack_id)
target_tracker.healing_scores.append(0)

# Intensity buffs with max_stacks > 1 (might, stability)
elif _max_stacks_for(buff_name) > 1:
Expand Down Expand Up @@ -726,7 +790,16 @@ def _process_buff_extension(self, event: BuffExtensionEvent) -> None:
) + 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
# QueueLogic: graft extension onto stack with matching stack_id,
# else fallback to closest remaining duration (EI behavior).
if event.stack_id is not None:
for i, sid in enumerate(target_tracker.stack_ids):
if sid == event.stack_id:
target_tracker.expirations[i] = (
target_tracker.expirations[i] or 0
) + event.extended_duration_ms
return
# Fallback: closest remaining 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))
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
66 changes: 59 additions & 7 deletions libs/gw2_analytics/src/gw2_analytics/rotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,8 @@
"23284B87C26C9A41A887F410F930E1A2": 13064, # Infiltrator's Signet
"BB5488951B60B546BB1BD5626DAE83E1": 13062, # Signet of Agility
"418A090D719AB44AAF1C4AD1473068C4": 43176, # Flash Spark
"D7F8FA5695F8714B99A5C3EE72EF6E178": 14413, # Dolyak Signet
"D7F8FA5695F8714B99A51EE72EF6E178": 14413, # Dolyak Signet
"D7F8FA5695F8714B99A5C3EE72EF6E178": 14413, # Dolyak Signet (variant)
"68F2C378E6C80548B5A3C89870C5DD86": 9085, # "Save Yourselves!"
"8D36806A690A5442A983308EDCECB018": 63195, # Unnatural Traversal
}
Expand Down Expand Up @@ -595,6 +596,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 +621,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 +956,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 +985,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 +1063,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 +1167,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
Loading
Loading