From 65ca84ad975f5694cba2079bdb3db0493a0389ec Mon Sep 17 00:00:00 2001 From: Roddy GitHub Date: Fri, 24 Jul 2026 22:48:58 +0200 Subject: [PATCH 1/2] fix(api): display profession names + cross-validate elite specs + tune role detection Signed-off-by: Roddy GitHub --- .../api/src/gw2analytics_api/route_helpers.py | 16 +++- .../src/gw2_analytics/role_detection.py | 9 ++- .../src/gw2_evtc_parser/parser.py | 80 +++++++++++++++++-- 3 files changed, 94 insertions(+), 11 deletions(-) diff --git a/apps/api/src/gw2analytics_api/route_helpers.py b/apps/api/src/gw2analytics_api/route_helpers.py index d725cf9c..465f29b2 100644 --- a/apps/api/src/gw2analytics_api/route_helpers.py +++ b/apps/api/src/gw2analytics_api/route_helpers.py @@ -16,9 +16,21 @@ def format_profession(profession: Profession | int) -> str: - """Map a profession to its wire-format label.""" + """Map a profession to its wire-format label. + + Returns the profession's display name (e.g. ``"Guardian"``, + ``"Warrior"``) for known core professions, ``"UNKNOWN"`` for + value 0, and ``"PROF(N)"`` for unknown profession IDs (the + fallback for future professions not yet in the ``Profession`` + enum). + """ v = profession.value if isinstance(profession, Profession) else int(profession) - return "UNKNOWN" if v == 0 else f"PROF({v})" + if v == 0: + return "UNKNOWN" + try: + return Profession(v).name.title() + except (ValueError, KeyError): + return f"PROF({v})" def format_elite_spec(elite: EliteSpec | int) -> str: diff --git a/libs/gw2_analytics/src/gw2_analytics/role_detection.py b/libs/gw2_analytics/src/gw2_analytics/role_detection.py index e10c4137..e5a9e0cf 100644 --- a/libs/gw2_analytics/src/gw2_analytics/role_detection.py +++ b/libs/gw2_analytics/src/gw2_analytics/role_detection.py @@ -238,7 +238,14 @@ # forces DPS as primary; with the override, HEAL takes # priority when the player is on a healer spec and has # meaningful healing output. -_R_HEAL_OVERRIDE: Final[float] = 0.15 +# +# Raised from 0.15 to 0.30 in v0.16.1-api follow-up: the +# previous threshold was too permissive, tagging low-healing +# player (e.g. a Scourge whose weighted effort was only ~15 % +# healing) as HEAL despite minimal actual heal output. The +# new threshold requires at least 30 % of the weighted effort +# to come from healing before the override kicks in. +_R_HEAL_OVERRIDE: Final[float] = 0.30 # --------------------------------------------------------------------------- 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 b3828fa9..eda865ca 100644 --- a/libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py +++ b/libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py @@ -371,6 +371,74 @@ _MAX_ZIP_ENTRY_UNCOMPRESSED_SIZE: Final[int] = 500 * 1024 * 1024 # 500 MB +# --------------------------------------------------------------------------- +# Elite-spec cross-validation (v0.16.1-api follow-up) +# +# arcdps EVTC2025+ builds sometimes write elite-spec values that do not +# correspond to the parsed profession (e.g. a Warrior with elite=74 which +# decodes to Virtuoso, a Mesmer-only spec). When this happens the parser +# degrades the elite to BASE (no elite) so downstream consumers see a +# coherent profession/spec pair. The raw byte is always preserved via +# ``Agent.elite_raw`` for forensics. +# +# Each profession lists the ELITE-SPEC INTEGER VALUES that are valid for +# it. Values not in this set are treated as corrupted/misaligned data and +# reset to BASE (0). Collisions (Soulbeast/Daredevil both = 55; +# Weaver/Renegade both = 63) are handled correctly: a Ranger with 55 +# matches, a Thief with 55 matches, an Ele with 63 matches, a Rev with +# 63 matches — the collision is resolved downstream by whoever first +# consumes ``EliteSpec(55)`` or ``EliteSpec(63)``. +# +# EoD specs (Willbender, Mechanist, Untamed, Catalyst, Virtuoso, +# Harbinger, Specter, Vindicator) and Janthir Wilds specs are included +# where the EliteSpec enum has a value. Specs not yet in the enum +# (Bladesworn, Luminary, etc.) are out of scope — the base-profession +# hint is the fallback anyway. +# --------------------------------------------------------------------------- + +_VALID_ELITE_BY_PROFESSION: Final[dict[int, frozenset[int]]] = { + Profession.GUARDIAN: frozenset({27, 62, 65}), # Dragonhunter, Firebrand, Willbender + Profession.WARRIOR: frozenset({18, 64}), # Berserker, Spellbreaker + Profession.ENGINEER: frozenset({43, 57, 70}), # Scrapper, Holosmith, Mechanist + Profession.RANGER: frozenset({5, 55, 73}), # Druid, Soulbeast, Untamed + Profession.THIEF: frozenset({55, 71, 72}), # Daredevil, Deadeye, Specter + Profession.ELEMENTALIST: frozenset({48, 63, 75}), # Tempest, Weaver, Catalyst + Profession.MESMER: frozenset({40, 59, 74}), # Chronomancer, Mirage, Virtuoso + Profession.NECROMANCER: frozenset({34, 60, 77}), # Reaper, Scourge, Harbinger + Profession.REVENANT: frozenset({52, 63, 68}), # Herald, Renegade, Vindicator +} + + +def _validate_elite_for_profession(profession_int: int, elite_int: int) -> EliteSpec: + """Return the validated elite spec for a profession+elite pair. + + If the elite spec value is valid for the given profession, return + ``EliteSpec(elite_int)``. Otherwise return ``EliteSpec.BASE`` (0) + and log a debug message so operators can investigate the source file. + + The raw ``elite_int`` is available as ``Agent.elite_raw`` for + forensics regardless of this validation outcome. + """ + if elite_int == 0: + return EliteSpec.BASE + valid = _VALID_ELITE_BY_PROFESSION.get(profession_int) + if valid is not None and elite_int in valid: + try: + return EliteSpec(elite_int) + except ValueError: + return EliteSpec.UNKNOWN + # Cross-validation failed: the elite spec does not belong to this + # profession. Degrade to BASE (no elite) so downstream consumers + # see a coherent profession/spec pair. + logger.debug( + "Elite spec %d invalid for profession %d (agent skipped); " + "raw value preserved in elite_raw for forensics", + elite_int, + profession_int, + ) + return EliteSpec.BASE + + # --------------------------------------------------------------------------- # Implementation # --------------------------------------------------------------------------- @@ -1593,10 +1661,8 @@ def _decode_agent_2025(data: bytes, offset: int) -> Agent: except ValueError: profession = Profession.UNKNOWN - try: - elite = EliteSpec(elite_raw) - except ValueError: - elite = EliteSpec.UNKNOWN + # v0.16.1-api: cross-validate elite spec against profession. + elite = _validate_elite_for_profession(int(prof_raw), int(elite_raw)) return Agent( id=addr, @@ -1656,10 +1722,8 @@ def _decode_agent(data: bytes, offset: int) -> Agent: except ValueError: profession = Profession.UNKNOWN - try: - elite = EliteSpec(elite_raw) - except ValueError: - elite = EliteSpec.UNKNOWN + # v0.16.1-api: cross-validate elite spec against profession. + elite = _validate_elite_for_profession(int(prof_raw), int(elite_raw)) return Agent( id=aid, From c77d50a411240616e96b04b0e8ec0bf0edbf5ae5 Mon Sep 17 00:00:00 2001 From: Roddy GitHub Date: Fri, 24 Jul 2026 23:02:54 +0200 Subject: [PATCH 2/2] fix(tests): update PROF(N) assertions to match new format_profession output Signed-off-by: Roddy GitHub --- apps/api/tests/routes/test_fights_readout.py | 50 ++++++++++---------- apps/api/tests/test_players.py | 10 ++-- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/apps/api/tests/routes/test_fights_readout.py b/apps/api/tests/routes/test_fights_readout.py index 5db60b88..c4ef70d7 100644 --- a/apps/api/tests/routes/test_fights_readout.py +++ b/apps/api/tests/routes/test_fights_readout.py @@ -87,7 +87,7 @@ def test_readout_200_happy_path_with_player(client: TestClient) -> None: assert a_row["subgroup"] == 0 assert a_row["name"] == f"W {suffix}" assert a_row["account_name"] == f"synth.{a}" - assert a_row["profession"] != "UNKNOWN" # format_profession(Warrior=2) -> "PROF(2)" + assert a_row["profession"] != "UNKNOWN" # format_profession(Warrior=2) -> "Warrior" assert a_row["elite_spec"] != "UNKNOWN" # format_elite_spec(Berserker=18) -> "Berserker" assert a_row["is_commander"] is False assert a_row["roles"] == ["DPS"] @@ -269,7 +269,7 @@ def test_readout_aggregator_stun_break_events_wired() -> None: name=f"W {a}", subgroup=0, account_name=f"synth.{a}", - profession="PROF(2)", + profession="Warrior", elite_spec="Berserker", is_player=True, is_commander=True, @@ -330,7 +330,7 @@ def test_readout_aggregator_account_name_none_passthrough() -> None: name=f"W {a}", subgroup=0, account_name=None, # the canonical absent-account path - profession="PROF(2)", + profession="Warrior", elite_spec="Berserker", is_player=True, is_commander=False, @@ -350,7 +350,7 @@ def test_readout_aggregator_account_name_none_passthrough() -> None: assert a_readout.account_name is None # The OTHER identity columns still hydrate correctly. assert a_readout.name == f"W {a}" - assert a_readout.profession == "PROF(2)" + assert a_readout.profession == "Warrior" assert a_readout.elite_spec == "Berserker" assert a_readout.is_commander is False # The pre-followup coerce of None to "" would have triggered @@ -411,7 +411,7 @@ def test_readout_boon_uptimes_and_presence_pct() -> None: name=f"BoonRecv {a}", subgroup=0, account_name=f"synth.{a}", - profession="PROF(2)", + profession="Warrior", elite_spec="Berserker", is_player=True, is_commander=False, @@ -421,7 +421,7 @@ def test_readout_boon_uptimes_and_presence_pct() -> None: name=f"BoonSrc {b}", subgroup=0, account_name=f"synth.{b}", - profession="PROF(1)", + profession="Guardian", elite_spec="Dragonhunter", is_player=True, is_commander=False, @@ -496,7 +496,7 @@ def test_readout_boon_uptimes_none_for_no_account() -> None: name=f"NoSynth {a}", subgroup=0, account_name=f"synth.{a}", - profession="PROF(2)", + profession="Warrior", elite_spec="Berserker", is_player=True, is_commander=False, @@ -573,7 +573,7 @@ def test_readout_phase6_v2_barrier_and_condi_split_live() -> None: name=f"Phase6v2 {a}", subgroup=0, account_name=f"synth.{a}", - profession="PROF(2)", + profession="Warrior", elite_spec="Berserker", is_player=True, is_commander=False, @@ -583,7 +583,7 @@ def test_readout_phase6_v2_barrier_and_condi_split_live() -> None: name=f"Phase6v2 {b}", subgroup=0, account_name=f"synth.{b}", - profession="PROF(1)", + profession="Guardian", elite_spec="Dragonhunter", is_player=True, is_commander=False, @@ -691,7 +691,7 @@ def test_readout_down_contribution_dps_wired() -> None: name=f"Killer {a}", subgroup=0, account_name=f"synth.{a}", - profession="PROF(2)", + profession="Warrior", elite_spec="Berserker", is_player=True, is_commander=False, @@ -701,7 +701,7 @@ def test_readout_down_contribution_dps_wired() -> None: name=f"Downed {b}", subgroup=0, account_name=f"synth.{b}", - profession="PROF(1)", + profession="Guardian", elite_spec="Dragonhunter", is_player=True, is_commander=False, @@ -768,7 +768,7 @@ def test_readout_cleave_targets() -> None: name=f"Cleaver {a}", subgroup=0, account_name=f"synth.{a}", - profession="PROF(2)", + profession="Warrior", elite_spec="Berserker", is_player=True, is_commander=False, @@ -778,7 +778,7 @@ def test_readout_cleave_targets() -> None: name=f"Target1 {b}", subgroup=0, account_name=f"synth.{b}", - profession="PROF(1)", + profession="Guardian", elite_spec="Dragonhunter", is_player=True, is_commander=False, @@ -788,7 +788,7 @@ def test_readout_cleave_targets() -> None: name=f"Target2 {c}", subgroup=0, account_name=f"synth.{c}", - profession="PROF(1)", + profession="Guardian", elite_spec="Dragonhunter", is_player=True, is_commander=False, @@ -825,7 +825,7 @@ def test_readout_dist_to_commander_no_commander() -> None: name=f"NoCmd {a}", subgroup=0, account_name=f"synth.{a}", - profession="PROF(2)", + profession="Warrior", elite_spec="Berserker", is_player=True, is_commander=False, @@ -901,7 +901,7 @@ def test_readout_dual_role_heal_support() -> None: name=f"HealSup {a}", subgroup=0, account_name=f"synth.{a}", - profession="PROF(1)", + profession="Guardian", elite_spec="Firebrand", is_player=True, is_commander=False, @@ -911,7 +911,7 @@ def test_readout_dual_role_heal_support() -> None: name=f"Minor {b}", subgroup=0, account_name=f"synth.{b}", - profession="PROF(2)", + profession="Warrior", elite_spec="Berserker", is_player=True, is_commander=False, @@ -977,7 +977,7 @@ def test_readout_cleanser_role() -> None: name=f"Cleanser {a}", subgroup=0, account_name=f"synth.{a}", - profession="PROF(1)", + profession="Guardian", elite_spec="Firebrand", is_player=True, is_commander=False, @@ -987,7 +987,7 @@ def test_readout_cleanser_role() -> None: name=f"Target {b}", subgroup=0, account_name=f"synth.{b}", - profession="PROF(2)", + profession="Warrior", elite_spec="Berserker", is_player=True, is_commander=False, @@ -1037,7 +1037,7 @@ def test_readout_cc_role() -> None: name=f"CCer {a}", subgroup=0, account_name=f"synth.{a}", - profession="PROF(2)", + profession="Warrior", elite_spec="Berserker", is_player=True, is_commander=False, @@ -1047,7 +1047,7 @@ def test_readout_cc_role() -> None: name=f"Target {b}", subgroup=0, account_name=f"synth.{b}", - profession="PROF(1)", + profession="Guardian", elite_spec="Dragonhunter", is_player=True, is_commander=False, @@ -1097,7 +1097,7 @@ def test_readout_strip_role() -> None: name=f"Stripper {a}", subgroup=0, account_name=f"synth.{a}", - profession="PROF(2)", + profession="Warrior", elite_spec="Berserker", is_player=True, is_commander=False, @@ -1107,7 +1107,7 @@ def test_readout_strip_role() -> None: name=f"Target {b}", subgroup=0, account_name=f"synth.{b}", - profession="PROF(1)", + profession="Guardian", elite_spec="Dragonhunter", is_player=True, is_commander=False, @@ -1183,7 +1183,7 @@ def test_readout_dist_to_commander_with_commander() -> None: name=f"Player {a}", subgroup=0, account_name=f"synth.{a}", - profession="PROF(2)", + profession="Warrior", elite_spec="Berserker", is_player=True, is_commander=False, @@ -1193,7 +1193,7 @@ def test_readout_dist_to_commander_with_commander() -> None: name=f"Cmd {c}", subgroup=0, account_name=f"synth.{c}", - profession="PROF(1)", + profession="Guardian", elite_spec="Dragonhunter", is_player=True, is_commander=True, diff --git a/apps/api/tests/test_players.py b/apps/api/tests/test_players.py index 54b4ccb5..4072e630 100644 --- a/apps/api/tests/test_players.py +++ b/apps/api/tests/test_players.py @@ -277,7 +277,7 @@ def test_players_filter_with_pagination() -> None: test, so the "many Mesmer accounts from prior runs" pollution is gone. We now seed 5 Mesmers deterministically to exercise the cross-page consistency contract: - 1. All rows on every page have ``profession == "PROF(7)"`` (Mesmer) + 1. All rows on every page have ``profession == "Mesmer"`` (Mesmer) -- the filter is applied to every page, not just page 1. 2. Page 1 + page 2 do not overlap -- the offset/limit are consistent on the filtered set. @@ -300,7 +300,7 @@ def test_players_filter_with_pagination() -> None: # applied -- if the filter were broken, page 1 might # include a Warrior or Necromancer). for row in rows1: - assert row["profession"] == "PROF(7)", f"page 1 row should be Mesmer (PROF(7)), got {row}" + assert row["profession"] == "Mesmer", f"page 1 row should be Mesmer (PROF(7)), got {row}" resp2 = client.get( "/api/v1/players", params={"profession": "MESMER", "limit": 2, "offset": 2}, @@ -309,7 +309,7 @@ def test_players_filter_with_pagination() -> None: rows2 = resp2.json() # Every row on page 2 is a Mesmer too. for row in rows2: - assert row["profession"] == "PROF(7)", f"page 2 row should be Mesmer (PROF(7)), got {row}" + assert row["profession"] == "Mesmer", f"page 2 row should be Mesmer (PROF(7)), got {row}" page1_accounts = {r["account_name"] for r in rows1} page2_accounts = {r["account_name"] for r in rows2} # Page 1 + page 2 must not overlap (the offset/limit @@ -351,9 +351,9 @@ def test_players_filter_does_not_affect_other_responses() -> None: # modal profession, not the Warrior filter value). assert profile["fights_attended"] >= 1 # The ``profession`` field is the wire-format string label - # (``"PROF(7)"`` for Mesmer -- see :func:`_profession_label` + # (``"Mesmer"`` for Mesmer -- see :func:`_profession_label` # in routes/players.py for the exact wire shape). - assert profile["profession"] == "PROF(7)" + assert profile["profession"] == "Mesmer" # v0.10.3 plan 083: the per-fight breakdown now carries the # role detection (detected_role / detected_tags). The Mesmer # seeded above has damage-only events (the back-and-forth