diff --git a/libs/gw2_analytics/src/gw2_analytics/ei_compare.py b/libs/gw2_analytics/src/gw2_analytics/ei_compare.py index 5ed2f91f..fa1a918c 100644 --- a/libs/gw2_analytics/src/gw2_analytics/ei_compare.py +++ b/libs/gw2_analytics/src/gw2_analytics/ei_compare.py @@ -99,6 +99,12 @@ def _rotation_unmatched( return missing, remaining +def _defense_landed_hit(event: DamageEvent) -> bool: + return landed_hit(event) and not ( + not event.is_condition and event.result == 10 and event.damage == 0 and event.buff_dmg == 0 + ) + + def _damage_stats( damage: list[DamageEvent], crowd_control: list[CCEvent], @@ -375,13 +381,16 @@ def down_rows_for_slice(lo: int, hi: int) -> dict[int, object]: sliced_down_cache[(lo, hi)] = cached return cached - agents_by_account = { - agent.account_name.lstrip(":"): agent for agent in fight.agents if agent.account_name - } + agents_by_account: dict[str, list[Agent]] = defaultdict(list) + for fight_agent in fight.agents: + if fight_agent.account_name: + agents_by_account[fight_agent.account_name.lstrip(":")].append(fight_agent) agents_by_instance: dict[int, Agent] = {} + agents_by_instance_entries: dict[int, list[Agent]] = defaultdict(list) for fight_agent in fight.agents: if fight_agent.instance_id: agents_by_instance.setdefault(fight_agent.instance_id, fight_agent) + agents_by_instance_entries[fight_agent.instance_id].append(fight_agent) agent_ids_by_instance: dict[int, set[int]] = defaultdict(set) for fight_agent in fight.agents: if fight_agent.instance_id: @@ -425,6 +434,42 @@ def indexed_events(index: dict[int, list[Any]], agent_ids: set[int]) -> list[Any indexed_event for agent_id in agent_ids for indexed_event in index.get(agent_id, []) ] + def select_player_agent(player: dict[str, Any], account: str) -> Agent | None: + instance_id = player.get("instanceID") + if account.startswith("Non Squad Player") and isinstance(instance_id, int): + anonymous = [ + agent + for agent in agents_by_instance_entries.get(instance_id, []) + if agent.account_name is None + ] + if anonymous: + return anonymous[0] + candidates = agents_by_account.get(account, []) + expected_name = player.get("name") + if isinstance(expected_name, str): + for agent in candidates: + if agent.name == expected_name: + return agent + expected_profession = player.get("profession") + if isinstance(expected_profession, str): + for agent in candidates: + if spec_display_name(agent.profession, agent.elite) == expected_profession: + return agent + if candidates: + return candidates[-1] + if isinstance(instance_id, int): + return agents_by_instance.get(instance_id) + return None + + def player_agent_ids(agent: Agent) -> set[int]: + if not agent.instance_id: + return {agent.id} + entries = agents_by_instance_entries.get(agent.instance_id, []) + accounts = {entry.account_name for entry in entries} + if len(accounts) > 1: + return {agent.id} + return agent_ids_by_instance.get(agent.instance_id, {agent.id}) + targets = expected.get("targets") if isinstance(expected.get("targets"), list) else [] buff_map = expected.get("buffMap") condition_skill_ids: set[int] | None = ( @@ -542,10 +587,7 @@ def indexed_events(index: dict[int, list[Any]], agent_ids: set[int]) -> list[Any if not isinstance(player, dict) or not isinstance(player.get("account"), str): continue account = player["account"] - instance_id = player.get("instanceID") - agent: Agent | None = agents_by_account.get(account) - if agent is None and isinstance(instance_id, int): - agent = agents_by_instance.get(instance_id) + agent = select_player_agent(player, account) if agent is None: compared_players[account] = None differences[f"players[{account}]"] = {"expected": "present", "actual": None} @@ -637,7 +679,7 @@ def in_slice(event: Event, _lo: int = slice_lo, _hi: int = slice_hi) -> bool: } defense_values = { "damageTaken": sum(event.damage for event in taken), - "damageTakenCount": sum(landed_hit(event) for event in taken), + "damageTakenCount": sum(_defense_landed_hit(event) for event in taken), "conditionDamageTaken": sum( _condition_damage(event, condition_skill_ids) for event in taken ), @@ -709,7 +751,7 @@ def in_slice(event: Event, _lo: int = slice_lo, _hi: int = slice_hi) -> bool: # exactly the absence window to every one of them -- the # signature was several buffs on one actor all overcounting by # the identical amount. - for alias_id in agent_ids: + for alias_id in player_agent_ids(agent): alias_uptime = tracker.compute_player_uptimes( alias_id, duration_ms, diff --git a/libs/gw2_analytics/src/gw2_analytics/rotation.py b/libs/gw2_analytics/src/gw2_analytics/rotation.py index f9531500..393bb6c8 100644 --- a/libs/gw2_analytics/src/gw2_analytics/rotation.py +++ b/libs/gw2_analytics/src/gw2_analytics/rotation.py @@ -97,6 +97,7 @@ 73071: 73037, 63145: 63344, 77095: 76813, + 76326: 76326, 76351: 76351, 62769: 62745, 78624: 76752, @@ -199,6 +200,7 @@ 62847: 50, # Unseen Sword 31658: 50, # Glyph of Equality 5572: 50, # Signet of Air (damage path; + effect-by-dst below) + 76315: 50, # Bloodstone Explosion } _DAMAGE_CASTS_BY_DAMAGE = {40071: 44428, 46808: 40813} _HEALING_CASTS = { @@ -219,6 +221,29 @@ 72115, } _MISSILE_CASTS = {26261, 29889, 42163} +_PENDING_EXPECTED_DURATION_SKILLS = { + 1097, + 5526, + 9086, + 9098, + 9143, + 9265, + 10237, + 14447, + 30825, + 31710, + 31968, + 62624, + 63066, + 63128, + 63220, + 71892, + 71986, + 72051, + 72940, + 76746, +} +_NEXT_ACTIVATION_CLAMP_SKILLS = {63267, 63128} _ATTUNEMENT_BUFFS = {5575, 5580, 5585, 5586} _WEAVER_ATTUNEMENTS = _ATTUNEMENT_BUFFS | { 40926, @@ -404,6 +429,7 @@ } _EFFECT_ELITE_GATE = { "418A090D719AB44AAF1C4AD1473068C4": EliteSpec.HOLOSMITH, # Flash Spark + "9C06D9D9B0E22247A1752C426808CD80": EliteSpec.HARBINGER, } _AEGIS_BUFF = 743 _STABILITY_BUFF = 1122 @@ -679,18 +705,21 @@ def add_instant(source: int, skill_id: int, time_ms: int, icd: int = 50) -> None if isinstance(event, SkillActivationEvent): key = (event.source_agent_id, event.skill_id) if event.activation in (ActivationType.NORMAL, ActivationType.QUICKNESS): - if pending := active.pop((event.source_agent_id, 63267), None): - casts.append( - SkillCast( - source_agent_id=pending.source_agent_id, - skill_id=pending.skill_id, - time_ms=pending.time_ms - origin, - duration_ms=min( - pending.expected_duration_ms, - event.time_ms - pending.time_ms + 10, - ), + for pending_skill_id in _NEXT_ACTIVATION_CLAMP_SKILLS: + if pending_skill_id == event.skill_id: + continue + if pending := active.pop((event.source_agent_id, pending_skill_id), None): + casts.append( + SkillCast( + source_agent_id=pending.source_agent_id, + skill_id=pending.skill_id, + time_ms=pending.time_ms - origin, + duration_ms=min( + pending.expected_duration_ms, + event.time_ms - pending.time_ms + 10, + ), + ) ) - ) if (pending := active.pop(key, None)) and pending.time_ms >= origin: casts.append( SkillCast( @@ -800,6 +829,19 @@ def add_instant(source: int, skill_id: int, time_ms: int, icd: int = 50) -> None mapped = None if has_walk_loss else 10687 if mapped is not None and ( (event.skill_id == 73955 and event.kind == "apply" and already_active) + or ( + event.skill_id == 73955 + and event.kind == "apply" + and sum( + isinstance(other, BoonApplyEvent) + and other.skill_id == 73955 + and other.kind == "apply" + and other.source_agent_id == event.source_agent_id + and other.target_agent_id == event.target_agent_id + for other in nearby_events(event.time_ms, 0) + ) + >= 3 + ) or (event.skill_id in {27581, 73955} and event.kind not in {"apply", "remove_all"}) or ( event.kind != "apply" @@ -954,6 +996,7 @@ def add_instant(source: int, skill_id: int, time_ms: int, icd: int = 50) -> None isinstance(other, BoonApplyEvent) and other.kind == "apply" and other.skill_id == 10243 + and other.added_active and other.target_agent_id == caster and abs(other.time_ms - event.time_ms) < 10 for other in nearby_events(event.time_ms, 9) @@ -968,9 +1011,39 @@ def add_instant(source: int, skill_id: int, time_ms: int, icd: int = 50) -> None else 10192 if distortion_buff_nearby else 10191 - if caster in mesmer_agent_ids + if ( + caster in mesmer_agent_ids + and elite_specs.get(caster) is not EliteSpec.CHRONOMANCER + ) else None ) + elif event.guid == "B0CF6359EBF9BF4EB94E1A2A347E5ECD": + if elite_specs.get(caster) is not EliteSpec.CONDUIT: + continue + has_nonself_nearby = any( + isinstance(other, EffectEvent) + and other.guid == event.guid + and other.source_agent_id == caster + and other.target_agent_id != caster + for other in nearby_events(event.time_ms, 2) + ) + effect_skill_id = ( + 77116 + if event.target_agent_id != caster + else None + if has_nonself_nearby + else 76818 + ) + elif event.guid == "239BF9EA9B747B44ACC63B86DC49B0D0": + has_distress_buffs = {73029, 73089} <= { + other.skill_id + for other in nearby_events(event.time_ms, 0) + if isinstance(other, BoonApplyEvent) + and other.kind == "apply" + and other.source_agent_id == caster + and other.target_agent_id == caster + } + effect_skill_id = 73116 if has_distress_buffs else None else: effect_skill_id = ( _EFFECT_CASTS_BY_DST.get(event.guid) @@ -1078,7 +1151,7 @@ def add_instant(source: int, skill_id: int, time_ms: int, icd: int = 50) -> None for pending in active.values(): duration = ( pending.expected_duration_ms - if pending.skill_id in {71892, 72940} + if pending.skill_id in _PENDING_EXPECTED_DURATION_SKILLS else pending.duration_ms ) casts.append( diff --git a/libs/gw2_analytics/tests/test_ei_compare.py b/libs/gw2_analytics/tests/test_ei_compare.py index e4810257..566e08ed 100644 --- a/libs/gw2_analytics/tests/test_ei_compare.py +++ b/libs/gw2_analytics/tests/test_ei_compare.py @@ -10,6 +10,7 @@ DamageEvent, DeathEvent, EliteSpec, + EvtcHeader, Fight, Profession, ) @@ -19,6 +20,15 @@ def test_compare_elite_insights_keeps_first_anonymous_agent_for_shared_instance( fight = Fight( id="fight", agents=[ + Agent( + id=99, + name="Named Player", + profession=Profession.GUARDIAN, + elite=EliteSpec.DRAGONHUNTER, + is_player=True, + account_name=":Named.1234", + instance_id=3994, + ), Agent( id=1, name="Chronomancienne", @@ -57,6 +67,125 @@ def test_compare_elite_insights_keeps_first_anonymous_agent_for_shared_instance( assert result["differences"] == {} +def test_compare_elite_insights_selects_split_account_agent_by_name() -> None: + fight = Fight( + id="fight", + header=EvtcHeader(build_version="20260224", agent_count=2, duration_ms=10_000), + agents=[ + Agent( + id=1, + name="First Character", + profession=Profession.NECROMANCER, + elite=EliteSpec.RITUALIST, + is_player=True, + account_name=":Player.1234", + subgroup="1", + instance_id=1111, + ), + Agent( + id=1, + name="Second Character", + profession=Profession.WARRIOR, + elite=EliteSpec.SPELLBREAKER, + is_player=True, + account_name=":Player.1234", + subgroup="1", + instance_id=1111, + ), + ], + ) + expected: dict[str, Any] = { + "players": [ + { + "account": "Player.1234", + "instanceID": 1111, + "firstAware": 0, + "name": "First Character", + "profession": "Ritualist", + "group": 1, + }, + { + "account": "Player.1234", + "instanceID": 1111, + "firstAware": 1, + "name": "Second Character", + "profession": "Spellbreaker", + "group": 1, + }, + ] + } + + result = compare_elite_insights(fight, expected, []) + + assert result["differences"] == {} + + +def test_compare_elite_insights_does_not_merge_shared_instance_buffs() -> None: + fight = Fight( + id="fight", + header=EvtcHeader(build_version="20260224", agent_count=2, duration_ms=10_000), + agents=[ + Agent( + id=1, + name="Named", + profession=Profession.ENGINEER, + elite=EliteSpec.SCRAPPER, + is_player=True, + account_name=":Named.1234", + instance_id=1111, + ), + Agent( + id=2, + name="Mécatronicienne", + profession=Profession.ENGINEER, + elite=EliteSpec.SCRAPPER, + is_player=True, + instance_id=1111, + ), + ], + ) + expected: dict[str, Any] = { + "players": [ + { + "account": "Named.1234", + "instanceID": 1111, + "name": "Named", + "buffUptimes": [{"id": 1187, "buffData": [{"uptime": 10.0}]}], + }, + { + "account": "Non Squad Player 1", + "instanceID": 1111, + "name": "Scrapper pl-1111", + "buffUptimes": [{"id": 1187, "buffData": [{"uptime": 20.0}]}], + }, + ] + } + events = [ + BoonApplyEvent( + time_ms=0, + source_agent_id=1, + target_agent_id=1, + skill_id=1187, + duration_ms=1_000, + stacks=1, + kind="apply", + ), + BoonApplyEvent( + time_ms=0, + source_agent_id=2, + target_agent_id=2, + skill_id=1187, + duration_ms=2_000, + stacks=1, + kind="apply", + ), + ] + + result = compare_elite_insights(fight, expected, events) + + assert result["differences"] == {} + + def test_compare_elite_insights_prefers_outcome_downs_for_named_players() -> None: fight = Fight( id="fight", @@ -140,3 +269,33 @@ def test_skill_stats_breakbar_grouprule() -> None: # Condition landed alongside breakbar: normals win, breakbar dropped. stats = _skill_stats([_dmg(10, True), _dmg(0, True, is_condition=True)]) assert stats[42]["connectedHits"] == 1 + + +def test_compare_elite_insights_excludes_breakbar_from_damage_taken_count() -> None: + fight = Fight( + id="fight", + agents=[ + Agent( + id=2, + name="Player", + profession=Profession.MESMER, + elite=EliteSpec.CHRONOMANCER, + is_player=True, + account_name=":Player.1234", + instance_id=1111, + ) + ], + ) + expected: dict[str, Any] = { + "players": [ + { + "account": "Player.1234", + "instanceID": 1111, + "defenses": [{"damageTakenCount": 0}], + } + ] + } + + result = compare_elite_insights(fight, expected, [_dmg(10, True)]) + + assert result["differences"] == {} diff --git a/libs/gw2_analytics/tests/test_rotation.py b/libs/gw2_analytics/tests/test_rotation.py index 9769fa70..0b358786 100644 --- a/libs/gw2_analytics/tests/test_rotation.py +++ b/libs/gw2_analytics/tests/test_rotation.py @@ -85,6 +85,293 @@ def test_build_skill_rotation_pairs_and_infers_casts() -> None: ] +def test_build_skill_rotation_uses_expected_duration_for_pending_all_in() -> None: + origin = 42_000_000 + events = [ + SkillActivationEvent( + time_ms=origin + 200, + source_agent_id=7, + target_agent_id=0, + skill_id=9265, + activation=ActivationType.NORMAL, + duration_ms=2_200, + expected_duration_ms=2_240, + ) + ] + + assert [ + (cast.skill_id, cast.time_ms, cast.duration_ms) + for cast in build_skill_rotation(events, duration_ms=3_000, start_time_ms=origin) + ] == [(9265, 200, 2_240)] + + +def test_build_skill_rotation_clamps_endless_night_to_next_activation() -> None: + origin = 42_000_000 + events = [ + SkillActivationEvent( + time_ms=origin + 100, + source_agent_id=7, + target_agent_id=0, + skill_id=63128, + activation=ActivationType.NORMAL, + duration_ms=2_200, + expected_duration_ms=2_300, + ), + SkillActivationEvent( + time_ms=origin + 2_322, + source_agent_id=7, + target_agent_id=0, + skill_id=63066, + activation=ActivationType.NORMAL, + duration_ms=800, + expected_duration_ms=840, + ), + SkillActivationEvent( + time_ms=origin + 5_000, + source_agent_id=7, + target_agent_id=0, + skill_id=63128, + activation=ActivationType.NORMAL, + duration_ms=2_200, + expected_duration_ms=2_300, + ), + ] + + assert [ + (cast.time_ms, cast.duration_ms) + for cast in build_skill_rotation(events, duration_ms=8_000, start_time_ms=origin) + if cast.skill_id == 63128 + ] == [(100, 2_232), (5_000, 2_300)] + + +def test_build_skill_rotation_ignores_inactive_distortion_shatter_refresh() -> None: + origin = 42_000_000 + events = [ + EffectEvent( + time_ms=origin + 100, + source_agent_id=7, + target_agent_id=999, + skill_id=36877, + guid="3D29ABD39CB5BD458C4D50A22FCC0E4B", + is_around_dst=True, + ), + BoonApplyEvent( + time_ms=origin + 100, + source_agent_id=7, + target_agent_id=7, + skill_id=10243, + duration_ms=3_000, + stacks=1, + added_active=False, + kind="apply", + ), + EffectEvent( + time_ms=origin + 1_000, + source_agent_id=7, + target_agent_id=999, + skill_id=36877, + guid="3D29ABD39CB5BD458C4D50A22FCC0E4B", + is_around_dst=True, + ), + BoonApplyEvent( + time_ms=origin + 1_000, + source_agent_id=7, + target_agent_id=7, + skill_id=10243, + duration_ms=3_000, + stacks=1, + added_active=True, + kind="apply", + ), + ] + + assert [ + (cast.skill_id, cast.time_ms, cast.duration_ms) + for cast in build_skill_rotation( + events, + duration_ms=2_000, + start_time_ms=origin, + virtuoso_agent_ids={7}, + ) + ] == [(68273, 1_000, 0)] + + +def test_build_skill_rotation_ignores_chronomancer_mind_wrack_effect() -> None: + origin = 42_000_000 + base = { + "target_agent_id": 999, + "skill_id": 36877, + "guid": "3D29ABD39CB5BD458C4D50A22FCC0E4B", + "is_around_dst": True, + } + events = [ + EffectEvent(time_ms=origin + 100, source_agent_id=7, **base), + EffectEvent(time_ms=origin + 200, source_agent_id=8, **base), + ] + + assert [ + (cast.source_agent_id, cast.skill_id, cast.time_ms) + for cast in build_skill_rotation( + events, + duration_ms=1_000, + start_time_ms=origin, + mesmer_agent_ids={7, 8}, + elite_specs={7: EliteSpec.CHRONOMANCER, 8: EliteSpec.MIRAGE}, + ) + ] == [(8, 10191, 200)] + + +def test_build_skill_rotation_ignores_relic_of_the_claw_multi_apply() -> None: + origin = 42_000_000 + base = { + "source_agent_id": 7, + "target_agent_id": 7, + "skill_id": 73955, + "duration_ms": 8_000, + "stacks": 1, + } + events = [ + BoonApplyEvent(time_ms=origin + 100, **base), + BoonApplyEvent(time_ms=origin + 200, **base), + BoonApplyEvent(time_ms=origin + 200, **base), + BoonApplyEvent(time_ms=origin + 200, **base), + ] + + assert [ + (cast.skill_id, cast.time_ms) + for cast in build_skill_rotation(events, duration_ms=1_000, start_time_ms=origin) + ] == [(73955, 100)] + + +def test_build_skill_rotation_gates_harbinger_effect() -> None: + origin = 42_000_000 + base = { + "skill_id": 38089, + "guid": "9C06D9D9B0E22247A1752C426808CD80", + "is_around_dst": True, + } + events = [ + EffectEvent(time_ms=origin + 100, source_agent_id=7, target_agent_id=7, **base), + EffectEvent(time_ms=origin + 200, source_agent_id=8, target_agent_id=8, **base), + ] + + assert [ + (cast.source_agent_id, cast.skill_id, cast.time_ms) + for cast in build_skill_rotation( + events, + duration_ms=1_000, + start_time_ms=origin, + elite_specs={7: EliteSpec.SCOURGE, 8: EliteSpec.HARBINGER}, + ) + ] == [(8, 62671, 200)] + + +def test_build_skill_rotation_infers_bloodstone_explosion_damage() -> None: + origin = 42_000_000 + events = [ + DamageEvent( + time_ms=origin + 100, + source_agent_id=7, + target_agent_id=999, + skill_id=76315, + damage=1_000, + result=1, + ) + ] + + assert [ + (cast.skill_id, cast.time_ms) + for cast in build_skill_rotation(events, duration_ms=1_000, start_time_ms=origin) + ] == [(76315, 100)] + + +def test_build_skill_rotation_infers_bloodstone_fervor_apply() -> None: + origin = 42_000_000 + events = [ + BoonApplyEvent( + time_ms=origin + 100, + source_agent_id=7, + target_agent_id=7, + skill_id=76326, + duration_ms=8_000, + stacks=1, + ) + ] + + assert [ + (cast.skill_id, cast.time_ms) + for cast in build_skill_rotation(events, duration_ms=1_000, start_time_ms=origin) + ] == [(76326, 100)] + + +def test_build_skill_rotation_infers_conduit_dervish_effects() -> None: + origin = 42_000_000 + base = { + "source_agent_id": 7, + "skill_id": 15483, + "guid": "B0CF6359EBF9BF4EB94E1A2A347E5ECD", + "is_around_dst": True, + } + events = [ + EffectEvent(time_ms=origin + 100, target_agent_id=7, **base), + EffectEvent(time_ms=origin + 200, target_agent_id=999, **base), + EffectEvent(time_ms=origin + 201, target_agent_id=7, **base), + ] + + assert [ + (cast.skill_id, cast.time_ms) + for cast in build_skill_rotation( + events, + duration_ms=1_000, + start_time_ms=origin, + elite_specs={7: EliteSpec.CONDUIT}, + ) + ] == [(76818, 100), (77116, 200)] + + +def test_build_skill_rotation_infers_distress_effect_with_buffs() -> None: + origin = 42_000_000 + events = [ + EffectEvent( + time_ms=origin + 100, + source_agent_id=7, + target_agent_id=7, + skill_id=40764, + guid="239BF9EA9B747B44ACC63B86DC49B0D0", + is_around_dst=True, + ), + BoonApplyEvent( + time_ms=origin + 200, + source_agent_id=7, + target_agent_id=7, + skill_id=73029, + duration_ms=0, + stacks=1, + ), + BoonApplyEvent( + time_ms=origin + 200, + source_agent_id=7, + target_agent_id=7, + skill_id=73089, + duration_ms=0, + stacks=1, + ), + EffectEvent( + time_ms=origin + 200, + source_agent_id=7, + target_agent_id=7, + skill_id=40764, + guid="239BF9EA9B747B44ACC63B86DC49B0D0", + is_around_dst=True, + ), + ] + + assert [ + (cast.skill_id, cast.time_ms) + for cast in build_skill_rotation(events, duration_ms=1_000, start_time_ms=origin) + ] == [(73116, 200)] + + def test_build_skill_rotation_infers_ei_instant_casts() -> None: origin = 42_000_000 base = {"target_agent_id": 7, "source_agent_id": 7} diff --git a/libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py b/libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py index cc8a024b..fe41d8c2 100644 --- a/libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py +++ b/libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py @@ -905,7 +905,14 @@ def parse_events(source: BinaryIO | bytes) -> Iterator[Event]: # noqa: PLR0912, ) yield EffectEvent( time_ms=time_ms, - source_agent_id=owner_by_agent.get(event_src_agent, event_src_agent), + # ponytail: keep the raw caster for Blood Moon. EI does not + # resolve the owner of a Stone Spirit's Blood Moon (the + # spirit is not a Druid, so the cast is not credited). + source_agent_id=( + event_src_agent + if effect_guids[skill_id] == "28346F32FD199C4B8F9B15438F27A434" + else owner_by_agent.get(event_src_agent, event_src_agent) + ), target_agent_id=dst_agent, skill_id=skill_id, guid=effect_guids[skill_id], diff --git a/libs/gw2_evtc_parser/tests/test_parser_emit_buff.py b/libs/gw2_evtc_parser/tests/test_parser_emit_buff.py index d8b6d196..08c35fa4 100644 --- a/libs/gw2_evtc_parser/tests/test_parser_emit_buff.py +++ b/libs/gw2_evtc_parser/tests/test_parser_emit_buff.py @@ -431,6 +431,53 @@ def test_parse_events_emits_weapon_swap_and_late_mapped_effect() -> None: assert events[1].guid == guid.hex().upper() +def test_parse_events_keeps_blood_moon_on_its_stone_spirit_caster() -> None: + """A Stone Spirit's Blood Moon stays credited to the spirit, not its owner. + + Elite Insights uses the effect's Src agent as-is: the Stone Spirit is not + a Druid, so its Blood Moon (skill 31749) is not credited to the owner. + """ + guid = bytes.fromhex("28346F32FD199C4B8F9B15438F27A434") + evtc = _build_minimal_evtc( + [(1, 1, 1, "Druid", True), (2, 1, 1, "Stone Spirit", False)], + build="20250925", + events=[ + _build_event_record_2025( + time_ms=1_000, + src_agent=1, + dst_agent=1, + value=3, + skill_id=0, + is_statechange=11, + src_instid=7, + ), + _build_event_record_2025( + time_ms=0, + src_agent=int.from_bytes(guid[:8], "little"), + dst_agent=int.from_bytes(guid[8:], "little"), + value=0, + skill_id=31749, + is_statechange=46, + ), + _build_event_record_2025( + time_ms=1_100, + src_agent=2, + dst_agent=3, + value=0, + skill_id=31749, + is_statechange=60, + src_master_instid=7, + ), + ], + ) + + (effect,) = [ + event for event in PythonEvtcParser().parse_events(evtc) if isinstance(event, EffectEvent) + ] + assert effect.guid == guid.hex().upper() + assert effect.source_agent_id == 2 + + def test_parse_events_emit_buff_remove_all_yields_boon_apply_event() -> None: """``is_buffremove == 1`` (CBTB_ALL) yields ONE BoonApplyEvent with kind="remove_all".