We are going to create a world of warcraft add on that finds the best talents and automatically enables them, with prompts to the user. We need to use the warcraftlogs to find the best M+ talents for each spec, and the best raid talents for each spec, and download them into a lua file that we can distribute with our add on. The addon should enable 'Loadouts' for the character, and when they enter a mythic level dungeon, or the current raid, it should select the appropriate talent. Note that there might be different best talents for different bosses in the raid - we will consider that a future feature. Ask me any questions you need answered, and create a ROADMAP.md file describing the work we are going to do to build this. Write the program to download content in python.
Task #2 created successfully: Ask clarifying questions
Task #3 created successfully: Propose approaches with trade-offs
Task #4 created successfully: Present design for user approval
Task #5 created successfully: Write design doc and ROADMAP.md
Updated task #1 status
The user wants to create a World of Warcraft addon that:
Let me first check if any skills apply. The brainstorming skill seems relevant since this is a creative/feature creation task. Let me invoke it first.
Actually, I'm in plan mode, so I need to follow the plan workflow. But I should also check skills. Let me invoke brainstorming first since this is a creative task.
Now let me explore the project context and research the key technical areas in parallel.
Perfect. I have a clear understanding now. Let me provide you with a comprehensive exploration report: ## Exploration Report: /mnt/dest-btrfs/home/jc/src/azerothtoptalent **Summary:** The directory `/mnt/dest-btrfs/home/jc/src/azerothtoptalent` is **completely empty**. It's a fresh, newly created directory with no existing files or code. **Detailed Findings:** 1. **Directory Status:** - Path: `/mnt/dest-btrfs/home/jc/src/azerothtoptalent` - Type: Directory (empty) - Size: 0 bytes - Permissions: 0755 (rwxr-xr-x) - Owner: jc (uid 1000) - Created: 2026-02-15 16:38:18 UTC - Last Modified: 2026-02-15 16:38:18 UTC 2. **Git Repository:** - Status: **NOT a git repository** - no .git directory - No commit history exists 3. **Project Files:** - No `package.json` (no Node.js project setup) - No `requirements.txt` (no Python project setup) - No `CLAUDE.md` or project documentation - No source code files (.js, .ts, .tsx, .jsx, .py, etc.) - No markdown files - No configuration files - No subdirectories 4. **Contents:** - Only the directory itself exists (indicated by `.` and `..` in the listing) - Completely empty of any files or folders **Conclusion:** This is a fresh, blank project directory ready for initialization. There is no existing codebase, configuration, documentation, or git history to work with. The directory was just created and is awaiting project setup. agentId: abedbdd (for resuming to continue this agent's work if needed) <usage>total_tokens: 13369 tool_uses: 9 duration_ms: 41343</usage>
Great! I found comprehensive information about the WarcraftLogs API v2. Let me provide you with a detailed summary of what I discovered: ## WarcraftLogs API v2 - Talent/Build Data Research ### 1. API Type: GraphQL WarcraftLogs v2 uses **GraphQL** as its primary API interface. The official API endpoint is at `https://www.warcraftlogs.com/api/v2/client` for public data. ### 2. Available API Endpoints for Talent Data The API provides several relevant endpoints documented at: - **ReportFight**: https://www.warcraftlogs.com/v2-api-docs/warcraft/reportfight.doc.html (contains `talentImportCode` field) - **Character**: https://www.warcraftlogs.com/v2-api-docs/warcraft/character.doc.html - **Report**: https://www.warcraftlogs.com/v2-api-docs/warcraft/report.doc.html - **Query**: https://www.warcraftlogs.com/v2-api-docs/warcraft/query.doc.html The key feature is the **`talentImportCode`** field in the ReportFight API, which allows you to retrieve talent import/export codes for specific actors in raid/M+ reports. ### 3. M+ and Raid Talent Builds While the search results mention **Top 100 Talent Heat Maps & Full Talent Trees** as a feature on WarcraftLogs (see the Wowhead article), the specific GraphQL query patterns for filtering by M+ vs raid encounters weren't detailed in these results. However, you can query reports by: - Encounter type (raid vs M+ dungeon) - Actor specifications - Generate talent codes via the `talentImportCode` parameter with an actorID ### 4. Authentication: OAuth 2.0 WarcraftLogs uses **OAuth 2.0** with three possible flows: **Client Credentials Flow** (for public API access): - No user authorization required - Access only public information - Simpler to implement **Authorization Code Flow** (for private data): - Requires user permission - Can access private reports - More complex setup **PKCE Flow** (for browser-based apps): - For applications without secure client secrets - Uses code challenge/verifier instead **To get started:** 1. Log in to WarcraftLogs 2. Go to the client management page: https://www.warcraftlogs.com/oauth/authorize (authorization URI) 3. Click "Create Client" 4. You'll receive: `client_id`, `client_secret` 5. Token endpoint: https://www.warcraftlogs.com/oauth/token ### 5. Response Format for Talent Data The API returns GraphQL JSON responses with a `talentImportCode` field that contains the actual talent build code (format used by WoW's import/export system). This code represents the exact talent configuration for a character in a specific fight. ### 6. GraphQL API Documentation - Official docs: https://www.warcraftlogs.com/api/docs - GraphQL schema browser: https://www.warcraftlogs.com/v2-api-docs/warcraft/ - You can use GraphQL clients like Insomnia or Altair to browse the schema interactively ## Open Source Projects Using WarcraftLogs API Several projects on GitHub demonstrate API usage: 1. **[oBusk/warcraftlogs-search](https://github.com/oBusk/warcraftlogs-search)** - Tool for finding logs filtered by talents and items 2. **[perdy/warcraftlogs](https://github.com/perdy/warcraftlogs)** - WarcraftLogs API client utilities 3. **[K0bus/warcraftlog-api-v2](https://github.com/K0bus/warcraftlog-api-v2)** - v2 API client 4. **[Jamesinvi/Warcraft-Data](https://github.com/Jamesinvi/Warcraft-Data)** - Web page for checking raid setups 5. **[Farah404/Raiddon-wclog-data](https://github.com/Farah404/Raiddon-wclog-data)** - OAuth2 Client Credentials example in Node.js and Python 6. **[Bradmiller72/warcraftlogs_scraper](https://github.com/Bradmiller72/warcraftlogs_scraper)** - Python scraper (work in progress) These projects can serve as reference implementations for building your talent data solution. --- Sources: - [WarcraftLogs API Documentation](https://www.warcraftlogs.com/api/docs) - [ReportFight API Documentation](https://www.warcraftlogs.com/v2-api-docs/warcraft/reportfight.doc.html) - [Character API Documentation](https://www.warcraftlogs.com/v2-api-docs/warcraft/character.doc.html) - [GraphQL Schema Documentation](https://www.warcraftlogs.com/v2-api-docs/warcraft/) - [API v2 Info/Documentation Forum](https://forums.combatlogforums.com/t/api-v2-info-documentation/9975) - [Wowhead News - Talent Heat Maps Feature](https://www.wowhead.com/news/new-warcraft-logs-features-top-100-talent-heat-maps-full-talent-trees-331478) - [oBusk/warcraftlogs-search](https://github.com/oBusk/warcraftlogs-search) - [Farah404/Raiddon-wclog-data](https://github.com/Farah404/Raiddon-wclog-data) - [Bradmiller72/warcraftlogs_scraper](https://github.com/Bradmiller72/warcraftlogs_scraper) agentId: a077208 (for resuming to continue this agent's work if needed) <usage>total_tokens: 15836 tool_uses: 4 duration_ms: 46971</usage>
Perfect! I've gathered comprehensive research on WoW addon talent system interaction. Let me compile the findings into a well-organized report.
## WoW Addon Talent System Research - Comprehensive Report
I've completed a thorough search of World of Warcraft addon documentation and APIs. Here's what I found:
---
### 1. WoW Lua API for Managing Talent Loadouts - C_ClassTalents API
**Core Functions:**
- **C_ClassTalents.LoadConfig(configID, autoApply)**
- Function Signature: `result, changeError, newLearnedNodeIDs = C_ClassTalents.LoadConfig(configID, autoApply)`
- Returns three values:
- `result` (Enum.LoadConfigResult): Status code with values:
- 0 = Error (Failed to switch loadouts)
- 1 = NoChangesNecessary (Previous and new loadouts identical)
- 2 = LoadInProgress (Cast bar triggered; wait before continuing)
- 3 = Ready (Changes applied without cast bar)
- `changeError` (string): Error message if applicable (added in patch 10.0.5)
- `newLearnedNodeIDs` (number[]): Array of newly learned talent node IDs
- Parameters: `configID` (number) and `autoApply` (boolean)
- Added in patch 10.0.0; enhanced in patch 10.0.5
- **C_ClassTalents.GetActiveConfigID()**
- Returns the configID for your currently selected specialization
- Each spec has an "active config" containing all active talents
- **C_ClassTalents.GetConfigIDsBySpecID([specID])**
- Returns a list of talent loadouts (configIDs) for a given specialization
- Parameters: Optional specID (defaults to current spec)
- List is available after TRAIT_CONFIG_LIST_UPDATED event fires
- Fires again on any changes to the loadout list
- **C_ClassTalents.GetLastSelectedSavedConfigID()**
- Tracks the last selected talent loadout
- **C_ClassTalents.ImportLoadout(configID, entries, name)**
- Function Signature: `success, importError = C_ClassTalents.ImportLoadout(configID, entries, name)`
- Parameters:
- `configID` (number): Configuration identifier
- `entries` (ImportLoadoutEntryInfo[]): Array of loadout entries containing:
- nodeID (number)
- ranksPurchased (number)
- selectionEntryID (number)
- `name` (string): Name for imported loadout
- Returns: `success` (boolean) and `importError` (string)
**Related Trait APIs:**
- **C_Traits.CommitConfig()** / **C_ClassTalents.CommitConfig()**
- Must be called to make talent changes permanent
- Changes are staged until committed
- **C_Traits.GenerateImportString()**
- Generates a talent build string (import/export format)
- **C_Traits.GenerateInspectImportString(unitID)**
- Generates talent string for inspected target
- Parameter: Unit ID (e.g., "target")
- Returns talent string or empty string if inspect unavailable
- **C_Traits.SetSelection(nodeID, selectionEntryID)**
- Sets individual talent selections
---
### 2. Programmatically Creating/Switching Talent Loadouts - Implementation Pattern
**Basic Talent Switch Pattern:**
```lua
-- Get available loadouts for current spec
local configIDs = C_ClassTalents.GetConfigIDsBySpecID()
-- Load a specific talent loadout
local result, changeError, newLearnedNodeIDs = C_ClassTalents.LoadConfig(configID, true)
-- Check result
if result == Enum.LoadConfigResult.LoadInProgress then
-- Wait for talent load to complete before continuing
elseif result == Enum.LoadConfigResult.Ready then
-- Changes applied immediately
elseif result == Enum.LoadConfigResult.Error then
print("Error loading config: " .. changeError)
end
```
**Key Implementation Notes:**
- There is **no API to determine which player-created loadout is currently selected**, which occasionally causes bugs
- Switching loadouts essentially means switching TraitConfig
- A config can contain multiple TraitTrees (though for talent trees there is only 1 TraitTree)
- The system supports Hero Talents introduced in The War Within expansion with separate currency systems
**Hierarchy Structure:**
System → Config → Tree → Node → Entry → Definition → Spell
---
### 3. WoW Events for Detecting Mythic+ Dungeons vs Raids
**Challenge Mode (Mythic+) Events:**
- **CHALLENGE_MODE_START**
- Fires when a Mythic+ dungeon starts
- Parameters: mapID
- Used to detect Mythic+ entry
- **CHALLENGE_MODE_COMPLETED**
- Fires upon Mythic+ completion
- Parameters: Rewards and timing data
- **CHALLENGE_MODE_RESET**
- Triggers when a challenge mode instance resets
**Encounter Events (Raids):**
- **ENCOUNTER_START**
- Fires when a raid/encounter begins
- Parameters: encounterID, encounterName, difficultyID, groupSize
- Allows distinguishing raid encounters from dungeons
- **ENCOUNTER_END**
- Triggers at encounter completion
- Includes success status
- **BOSS_KILL**
- Signals a boss defeat
- Parameters: encounterID, encounterName
**Zone Change Events:**
- **ZONE_CHANGED_NEW_AREA**
- Fires when player enters a new zone
- Used to detect zone transitions into dungeons/raids
- **INSTANCE_LOCK_START** / **INSTANCE_LOCK_STOP**
- Indicates instance lockout status changes
- **UPDATE_INSTANCE_INFO**
- Provides current instance information updates
- **INSTANCE_ENCOUNTER**
- Category events for tracking instanceEncounter updates
**Event Listening Pattern:**
```lua
local frame = CreateFrame("Frame")
frame:RegisterEvent("CHALLENGE_MODE_START")
frame:RegisterEvent("ENCOUNTER_START")
frame:SetScript("OnEvent", function(self, event, ...)
if event == "CHALLENGE_MODE_START" then
-- Handle Mythic+ start
elseif event == "ENCOUNTER_START" then
-- Handle raid encounter start
end
end)
```
---
### 4. Talent Export String Format (The War Within / Current)
**Format Overview:**
- **Encoding Type**: Base64-encoded binary (with custom variant)
- **Contains**: Build's spec, checksum of talent tree, selected talent information
- **Important Note**: The string uses a **non-standard base64 variant** - conventional base64 decoders will fail
**String Generation:**
Use API functions to generate talent strings rather than manual encoding:
```lua
-- Generate current talent string
local talentString = C_Traits.GenerateImportString()
-- Generate string for inspected target
local inspectString = C_Traits.GenerateInspectImportString("target")
```
**Technical Implementation:**
- Blizzard's encoding/decoding logic documented in `Blizzard_ClassTalentImportExport.lua` game file
- The Lua file contains complete implementation details for custom base64 variant
- Addons should use C_Traits APIs rather than manually parsing strings
**Data Structure Within String:**
- Spec identifier
- Tree checksum
- Selected talents with rankings/purchases
- Hero talent selections (introduced in The War Within)
---
### 5. Existing Addon Implementations - BtWLoadouts, Talent Loadout Manager, etc.
**Popular Talent Switching Addons:**
1. **BtWLoadouts** (2.8M+ downloads on CurseForge)
- GitHub: https://github.com/Breeni/BtWLoadouts
- Features:
- Manages talent, PvP talent, essence, soulbinds, equipment sets, action bars
- Combines sets into profiles switchable at once
- Supports importing Blizzard talent loadout strings directly
- Buttons to swap between Class and Spec talent trees
- Slash commands to activate profiles
- Conditions/automation to prompt switching in different situations
- Key Files: Talents.lua, DFTalents.lua, HeroTalents.lua, Events.lua, Loadouts.lua
- Uses event handlers for profile switching automation
2. **Talent Loadout Manager** (365.5K+ downloads)
- Provides public API for other addons to switch talent loadouts
- Manages talent loadout creation and switching
3. **Talent Loadout Ex**
- Focused on talent loadout management with import/export
4. **Talent Loadout Broker**
- Lightweight LibDataBroker plugin
- Allows talent loadout switching
- Requires display addon (TitanPanel, ChocolateBar, ElvUI, etc.)
5. **Other Specialized Addons:**
- **MythicPlusTimer**: Detects Mythic+ start and shows available information
- **Angry Keystones**: Enhances M+ objective tracker with secondary timer
- **SwitchSwitch** (https://github.com/peti446/SwitchSwitch): Auto-switches talents based on instance type with prompt configuration
**Common Implementation Patterns:**
- Event-driven talent switching using CHALLENGE_MODE_START, ENCOUNTER_START, ZONE_CHANGED_NEW_AREA
- Storage of multiple loadout configurations with metadata
- Integration with player specialization changes
- Support for import/export via talent strings
- Configuration UI for rules-based switching
**Important Limitation:**
- No built-in API to reliably determine which loadout is currently active
- Addons track active loadout separately, which can occasionally desynchronize
---
## Summary
The WoW talent system provides comprehensive APIs through `C_ClassTalents` and `C_Traits` namespaces for addon developers to:
- Create and switch talent loadouts programmatically
- Import/export talent configurations via strings
- Detect dungeon/raid entry points through events
- Automate talent switching based on game context
The key to robust addon development is using the event system to detect context (CHALLENGE_MODE_START for M+, ENCOUNTER_START for raids) and the C_ClassTalents API to switch loadouts, while maintaining separate tracking of active loadout state due to API limitations.
---
## Sources:
- [API C_ClassTalents.LoadConfig - Warcraft Wiki](https://warcraft.wiki.gg/wiki/API_C_ClassTalents.LoadConfig)
- [API C_ClassTalents.GetActiveConfigID - Warcraft Wiki](https://warcraft.wiki.gg/wiki/API_C_ClassTalents.GetActiveConfigID)
- [API C_ClassTalents.GetConfigIDsBySpecID - Warcraft Wiki](https://warcraft.wiki.gg/wiki/API_C_ClassTalents.GetConfigIDsBySpecID)
- [API C_ClassTalents.ImportLoadout - Wowpedia](https://wowpedia.fandom.com/wiki/API_C_ClassTalents.ImportLoadout)
- [Dragonflight Talent System - Warcraft Wiki](https://warcraft.wiki.gg/wiki/Dragonflight_Talent_System)
- [Talent Loadout Manager - CurseForge](https://www.curseforge.com/wow/addons/talent-loadout-manager)
- [BtWLoadouts - CurseForge](https://www.curseforge.com/wow/addons/btwloadouts)
- [BtWLoadouts - GitHub](https://github.com/Breeni/BtWLoadouts)
- [API C_Traits.GenerateImportString - Wowpedia](https://wowpedia.fandom.com/wiki/API_C_Traits.GenerateImportString)
- [Events - Warcraft Wiki](https://warcraft.wiki.gg/wiki/Events)
- [Talent build string sharing docs - WoW Forums](https://us.forums.blizzard.com/en/wow/t/talent-build-string-sharing-docs/1404862)
- [WoW addon detect mythic plus start - Various addons and guides](https://www.curseforge.com/wow/addons/mythicplustimer)
- [SwitchSwitch Addon - GitHub](https://github.com/peti446/SwitchSwitch)
agentId: a0f2736 (for resuming to continue this agent's work if needed)
<usage>total_tokens: 27782
tool_uses: 20
duration_ms: 811338</usage>Updated task #1 status
I'll start by invoking the brainstorming skill since this is a new feature/project creation task.
Updated task #2 status
Excellent research results. The project directory is empty (fresh start), and I now have a solid understanding of:
talentImportCode field in ReportFight dataC_ClassTalents.LoadConfig(), C_ClassTalents.ImportLoadout(), talent export stringsCHALLENGE_MODE_START for M+, ENCOUNTER_START for raids, ZONE_CHANGED_NEW_AREA for zone detectionLet me start with my first clarifying question:
User has answered your questions: "How should the addon handle talent switching — should it automatically apply the loadout silently, or prompt the user with a confirmation dialog before switching?"="Prompt first (Recommended)". You can now continue with the user's answers in mind.
Good — prompt-first approach. Next question:
User has answered your questions: "For the Python data pipeline, how should we determine 'best' talents from WarcraftLogs? The API gives us access to individual fight reports with talent codes, but we need to aggregate across many top players."="Top rankings approach (Recommended)". You can now continue with the user's answers in mind.
User has answered your questions: "Should the addon ship with pre-baked talent data (generated by the Python script and bundled as a Lua data file), or should it fetch updated data dynamically at runtime?"="Pre-baked Lua file (Recommended)". You can now continue with the user's answers in mind.
Launching skill: superpowers:brainstorming
User has answered your questions: "What should the addon be called? The project directory is 'azerothtoptalent' - should we use that, or do you have a different name in mind?"="it should be AzerothsTopTalent (that was a mistake in the directory name)". You can now continue with the user's answers in mind.
User has answered your questions: "Should the addon support all classes and specs from day one, or should we start with a subset and expand?"="All specs from day one (Recommended)". You can now continue with the user's answers in mind.
Updated task #2 status
Updated task #3 status
Now I have enough context to design the architecture. Let me launch Plan agents to design the two major components.
Now I have enough information to design the complete pipeline. Let me compile all findings into the comprehensive design plan.
---
# Python Data Pipeline Design for AzerothsTopTalent
## 1. Research Summary and Key API Findings
### WarcraftLogs API v2 Architecture
There are two viable approaches to extracting talent data, and they differ significantly in API cost and complexity:
**Approach A: Encounter characterRankings (Rankings-based)**
- Query path: `worldData > encounter(id) > characterRankings(className, specName, difficulty, metric, page, includeCombatantInfo)`
- Returns a JSON blob (opaque `JSON` scalar type) containing a `rankings` array with player entries
- Each ranking entry includes fields like `name`, `server`, `amount`, `class`, `spec`, and a `report` object containing `code` and `fightID`
- The `includeCombatantInfo: true` parameter enriches each ranking entry with combatant information (gear, stats, and potentially talent data)
- The talent import code is NOT directly on the characterRankings response; instead you get the `report.code` and `report.fightID` for each ranked player
**Approach B: Report-based talentImportCode (Two-step)**
- Step 1: Get ranked players from `characterRankings` (same as above) to get `report.code` and `report.fightID` for each
- Step 2: For each report+fight, query `reportData > report(code) > fights(fightIDs) > talentImportCode(actorID)` to get the actual talent import string
- The `talentImportCode` field lives on `ReportFight`, takes an `actorID` parameter, and returns a String (the WoW talent loadout string)
- You need to resolve `actorID` from `report > masterData > actors(type: "Player")` to map player names to actor IDs
### Critical Design Decision: Which approach?
**Recommendation: Use Approach A with `includeCombatantInfo: true` first.** Based on my research, when `includeCombatantInfo` is enabled, the JSON response for each ranking entry likely includes talent tree information (gear, stats, talents) inline. This avoids the N+1 query problem of Approach B. If the combatant info does not include the talent import string directly, we fall back to Approach B (two-step with report lookups).
The pipeline should be designed to handle both approaches, with Approach B as a fallback. During initial development, you will need to inspect the actual API response with `includeCombatantInfo: true` to confirm which talent data fields are present.
### Rate Limiting
- 3,600 points per hour (free tier)
- 36,000 points per hour (premium/Patreon tier)
- Point cost varies by query complexity
- HTTP 429 when exceeded
- A `rateLimitData` query exists to check current usage
### Zone and Encounter IDs (TWW Season 2, current as of Feb 2025)
- Raid zone: **42** (Liberation of Undermine)
- M+ zone: **43** (Mythic+ Season 2)
- Individual encounter IDs must be discovered dynamically via `worldData > zone(id) > encounters { id name }`
---
## 2. File/Module Structure
```
data_pipeline/
__init__.py
__main__.py # Entry point: python -m data_pipeline
config.py # Configuration, env vars, constants
auth.py # OAuth2 client credentials flow
api_client.py # GraphQL client with rate limiting
queries.py # All GraphQL query strings
specs.py # Complete list of 13 classes / 39 specs
encounter_discovery.py # Discover current zone/encounter IDs
rankings_fetcher.py # Fetch rankings for each spec+content type
talent_extractor.py # Extract talent codes from ranking data
talent_aggregator.py # Aggregate and determine "best" build
lua_writer.py # Generate TalentData.lua output
cache.py # Optional: disk cache for API responses
models.py # Dataclasses for structured data
```
---
## 3. Module-by-Module Design
### 3.1 `config.py` -- Configuration
```python
import os
from dataclasses import dataclass
@dataclass
class Config:
# Auth
client_id: str
client_secret: str
token_url: str = "https://www.warcraftlogs.com/oauth/token"
api_url: str = "https://www.warcraftlogs.com/api/v2/client"
# Content zones (TWW Season 2)
raid_zone_id: int = 42 # Liberation of Undermine
mplus_zone_id: int = 43 # M+ Season 2
raid_difficulty: int = 5 # Mythic (4=Heroic, 5=Mythic)
# Scraping parameters
top_n_players: int = 50 # How many top players per spec per encounter
pages_per_query: int = 1 # Pages of rankings to fetch (100 per page)
# Rate limiting
request_delay_seconds: float = 1.0 # Minimum delay between requests
max_retries: int = 3
# Output
output_path: str = "TalentData.lua"
cache_dir: str = ".cache"
@classmethod
def from_env(cls) -> "Config":
return cls(
client_id=os.environ["WCL_CLIENT_ID"],
client_secret=os.environ["WCL_CLIENT_SECRET"],
# Override defaults from env if present
raid_zone_id=int(os.environ.get("WCL_RAID_ZONE_ID", "42")),
mplus_zone_id=int(os.environ.get("WCL_MPLUS_ZONE_ID", "43")),
top_n_players=int(os.environ.get("WCL_TOP_N", "50")),
output_path=os.environ.get("WCL_OUTPUT_PATH", "TalentData.lua"),
)
```
### 3.2 `auth.py` -- OAuth2 Client Credentials
```python
import httpx
def get_access_token(client_id: str, client_secret: str, token_url: str) -> str:
"""Exchange client credentials for a bearer token."""
response = httpx.post(
token_url,
data={"grant_type": "client_credentials"},
auth=(client_id, client_secret),
)
response.raise_for_status()
return response.json()["access_token"]
```
### 3.3 `api_client.py` -- GraphQL Client with Rate Limiting
```python
import time
import logging
import httpx
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@dataclass
class WCLClient:
api_url: str
access_token: str
request_delay: float = 1.0
max_retries: int = 3
_last_request_time: float = field(default=0.0, init=False)
def query(self, graphql_query: str, variables: dict | None = None) -> dict:
"""Execute a GraphQL query with rate limiting and retry logic."""
for attempt in range(self.max_retries):
self._rate_limit()
try:
response = httpx.post(
self.api_url,
json={"query": graphql_query, "variables": variables or {}},
headers={
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
},
timeout=30.0,
)
if response.status_code == 429:
wait_time = 2 ** attempt * 10 # Exponential backoff: 10s, 20s, 40s
logger.warning(f"Rate limited (429). Waiting {wait_time}s...")
time.sleep(wait_time)
continue
response.raise_for_status()
data = response.json()
if "errors" in data:
logger.error(f"GraphQL errors: {data['errors']}")
raise RuntimeError(f"GraphQL query failed: {data['errors']}")
return data["data"]
except httpx.HTTPStatusError as e:
if attempt == self.max_retries - 1:
raise
logger.warning(f"HTTP error {e.response.status_code}, retrying...")
time.sleep(2 ** attempt)
raise RuntimeError("Max retries exceeded")
def _rate_limit(self):
elapsed = time.time() - self._last_request_time
if elapsed < self.request_delay:
time.sleep(self.request_delay - elapsed)
self._last_request_time = time.time()
```
### 3.4 `queries.py` -- GraphQL Queries
```python
# Discover encounters for a zone
ZONE_ENCOUNTERS_QUERY = """
query ZoneEncounters($zoneId: Int!) {
worldData {
zone(id: $zoneId) {
id
name
encounters {
id
name
}
}
}
}
"""
# Get character rankings for a specific encounter + spec
# This is the primary query. The response JSON includes a "rankings" array.
# Each entry has: name, class, spec, amount, server{name, region},
# report{code, fightID}, and when includeCombatantInfo is true,
# additional combatant data.
ENCOUNTER_RANKINGS_QUERY = """
query EncounterRankings(
$encounterId: Int!,
$className: String!,
$specName: String!,
$difficulty: Int!,
$metric: CharacterRankingMetricType,
$page: Int,
$includeCombatantInfo: Boolean
) {
worldData {
encounter(id: $encounterId) {
name
characterRankings(
className: $className,
specName: $specName,
difficulty: $difficulty,
metric: $metric,
page: $page,
includeCombatantInfo: $includeCombatantInfo
)
}
}
}
"""
# Fallback: Get talentImportCode from a specific report fight
# Used if includeCombatantInfo does not yield talent strings
REPORT_TALENT_QUERY = """
query ReportTalent($reportCode: String!, $fightIds: [Int!]) {
reportData {
report(code: $reportCode) {
masterData {
actors(type: "Player") {
id
name
server
subType
}
}
fights(fightIDs: $fightIds) {
id
friendlyPlayers
}
}
}
}
"""
# Get talent import code for a specific actor in a fight
# Must be called per fight because talentImportCode is on ReportFight
TALENT_IMPORT_CODE_QUERY = """
query TalentImportCode($reportCode: String!, $fightIds: [Int!], $actorId: Int!) {
reportData {
report(code: $reportCode) {
fights(fightIDs: $fightIds) {
id
talentImportCode(actorID: $actorId)
}
}
}
}
"""
# Check current rate limit status
RATE_LIMIT_QUERY = """
query {
rateLimitData {
limitPerHour
pointsSpentThisHour
pointsResetIn
}
}
"""
```
### 3.5 `specs.py` -- Complete Class/Spec Registry
```python
from dataclasses import dataclass
@dataclass(frozen=True)
class SpecInfo:
class_name: str # WCL className slug (e.g., "DeathKnight")
spec_name: str # WCL specName slug (e.g., "Blood")
role: str # "dps", "healer", "tank"
# All 39 specs in TWW (13 classes)
ALL_SPECS: list[SpecInfo] = [
# Death Knight
SpecInfo("DeathKnight", "Blood", "tank"),
SpecInfo("DeathKnight", "Frost", "dps"),
SpecInfo("DeathKnight", "Unholy", "dps"),
# Demon Hunter
SpecInfo("DemonHunter", "Havoc", "dps"),
SpecInfo("DemonHunter", "Vengeance", "tank"),
# Druid
SpecInfo("Druid", "Balance", "dps"),
SpecInfo("Druid", "Feral", "dps"),
SpecInfo("Druid", "Guardian", "tank"),
SpecInfo("Druid", "Restoration", "healer"),
# Evoker
SpecInfo("Evoker", "Devastation", "dps"),
SpecInfo("Evoker", "Preservation", "healer"),
SpecInfo("Evoker", "Augmentation", "dps"),
# Hunter
SpecInfo("Hunter", "BeastMastery", "dps"),
SpecInfo("Hunter", "Marksmanship", "dps"),
SpecInfo("Hunter", "Survival", "dps"),
# Mage
SpecInfo("Mage", "Arcane", "dps"),
SpecInfo("Mage", "Fire", "dps"),
SpecInfo("Mage", "Frost", "dps"),
# Monk
SpecInfo("Monk", "Brewmaster", "tank"),
SpecInfo("Monk", "Mistweaver", "healer"),
SpecInfo("Monk", "Windwalker", "dps"),
# Paladin
SpecInfo("Paladin", "Holy", "healer"),
SpecInfo("Paladin", "Protection", "tank"),
SpecInfo("Paladin", "Retribution", "dps"),
# Priest
SpecInfo("Priest", "Discipline", "healer"),
SpecInfo("Priest", "Holy", "healer"),
SpecInfo("Priest", "Shadow", "dps"),
# Rogue
SpecInfo("Rogue", "Assassination", "dps"),
SpecInfo("Rogue", "Outlaw", "dps"),
SpecInfo("Rogue", "Subtlety", "dps"),
# Shaman
SpecInfo("Shaman", "Elemental", "dps"),
SpecInfo("Shaman", "Enhancement", "dps"),
SpecInfo("Shaman", "Restoration", "healer"),
# Warlock
SpecInfo("Warlock", "Affliction", "dps"),
SpecInfo("Warlock", "Demonology", "dps"),
SpecInfo("Warlock", "Destruction", "dps"),
# Warrior
SpecInfo("Warrior", "Arms", "dps"),
SpecInfo("Warrior", "Fury", "dps"),
SpecInfo("Warrior", "Protection", "tank"),
]
def get_metric_for_role(role: str) -> str:
"""Return the appropriate WCL metric for a role."""
return {
"dps": "dps",
"healer": "hps",
"tank": "dps", # Tanks are typically ranked by DPS on WCL
}[role]
```
### 3.6 `models.py` -- Data Models
```python
from dataclasses import dataclass, field
@dataclass
class RankedPlayer:
name: str
server: str
region: str
amount: float # DPS/HPS value
report_code: str
fight_id: int
class_name: str
spec_name: str
talent_import_code: str | None = None
@dataclass
class EncounterInfo:
id: int
name: str
@dataclass
class ZoneInfo:
id: int
name: str
encounters: list[EncounterInfo]
@dataclass
class TalentBuild:
import_code: str
count: int = 0 # How many top players use this exact build
total_score: float = 0.0 # Sum of ranking scores for weighting
sample_players: list[str] = field(default_factory=list)
@dataclass
class SpecBuildResult:
class_name: str
spec_name: str
content_type: str # "raid" or "mythicplus"
best_build: str # The winning talent import code
build_count: int # How many top players used it
total_players_sampled: int
all_builds: list[TalentBuild] # All builds found, sorted by popularity
```
### 3.7 `encounter_discovery.py` -- Dynamic Zone/Encounter Discovery
```python
from api_client import WCLClient
from queries import ZONE_ENCOUNTERS_QUERY
from models import ZoneInfo, EncounterInfo
def discover_zone_encounters(client: WCLClient, zone_id: int) -> ZoneInfo:
"""Fetch all encounters for a given zone ID."""
data = client.query(ZONE_ENCOUNTERS_QUERY, {"zoneId": zone_id})
zone_data = data["worldData"]["zone"]
return ZoneInfo(
id=zone_data["id"],
name=zone_data["name"],
encounters=[
EncounterInfo(id=e["id"], name=e["name"])
for e in zone_data["encounters"]
],
)
```
### 3.8 `rankings_fetcher.py` -- Core Rankings Fetching
```python
import logging
from api_client import WCLClient
from queries import ENCOUNTER_RANKINGS_QUERY
from models import RankedPlayer, EncounterInfo
from specs import SpecInfo, get_metric_for_role
logger = logging.getLogger(__name__)
def fetch_rankings_for_spec_encounter(
client: WCLClient,
encounter: EncounterInfo,
spec: SpecInfo,
difficulty: int,
page: int = 1,
) -> list[RankedPlayer]:
"""Fetch top-ranked players for a specific spec + encounter combination."""
metric = get_metric_for_role(spec.role)
data = client.query(
ENCOUNTER_RANKINGS_QUERY,
{
"encounterId": encounter.id,
"className": spec.class_name,
"specName": spec.spec_name,
"difficulty": difficulty,
"metric": metric,
"page": page,
"includeCombatantInfo": True,
},
)
rankings_json = data["worldData"]["encounter"]["characterRankings"]
# characterRankings returns a JSON scalar -- it is a dict, not a typed object
rankings = rankings_json.get("rankings", [])
players = []
for entry in rankings:
talent_code = _extract_talent_code(entry)
player = RankedPlayer(
name=entry.get("name", "Unknown"),
server=entry.get("server", {}).get("name", "Unknown"),
region=entry.get("server", {}).get("region", "Unknown"),
amount=entry.get("amount", 0),
report_code=entry.get("report", {}).get("code", ""),
fight_id=entry.get("report", {}).get("fightID", 0),
class_name=spec.class_name,
spec_name=spec.spec_name,
talent_import_code=talent_code,
)
players.append(player)
logger.info(
f"Fetched {len(players)} rankings for {spec.class_name}/{spec.spec_name} "
f"on {encounter.name}"
)
return players
def _extract_talent_code(entry: dict) -> str | None:
"""
Extract talent import code from a ranking entry.
When includeCombatantInfo is True, the entry may contain talent data
in various possible locations. This function tries each known location.
If none found, returns None (caller should use the report-based fallback).
"""
# Try direct field (if WCL includes it inline)
if "talentImportCode" in entry:
return entry["talentImportCode"]
# Try nested in combatantInfo
combatant_info = entry.get("combatantInfo", {})
if isinstance(combatant_info, dict):
if "talentImportCode" in combatant_info:
return combatant_info["talentImportCode"]
# May be under "talents" or "talentTree"
if "talentCode" in combatant_info:
return combatant_info["talentCode"]
return None
```
### 3.9 `talent_extractor.py` -- Report-based Fallback Extraction
```python
import logging
from api_client import WCLClient
from queries import REPORT_TALENT_QUERY, TALENT_IMPORT_CODE_QUERY
from models import RankedPlayer
logger = logging.getLogger(__name__)
def fetch_talent_codes_from_reports(
client: WCLClient,
players: list[RankedPlayer],
) -> list[RankedPlayer]:
"""
Fallback: For players missing talent codes, look them up via report data.
Groups players by report code to minimize API calls.
"""
# Group players by report code
reports: dict[str, list[RankedPlayer]] = {}
for p in players:
if p.talent_import_code is None and p.report_code:
reports.setdefault(p.report_code, []).append(p)
if not reports:
return players # All players already have talent codes
logger.info(f"Fetching talent codes from {len(reports)} reports...")
for report_code, report_players in reports.items():
fight_ids = list({p.fight_id for p in report_players})
# Step 1: Get actor mappings
report_data = client.query(
REPORT_TALENT_QUERY,
{"reportCode": report_code, "fightIds": fight_ids},
)
report = report_data["reportData"]["report"]
actors = report.get("masterData", {}).get("actors", [])
# Build name -> actorID mapping
name_to_actor = {a["name"]: a["id"] for a in actors}
# Step 2: For each player, get their talent import code
for player in report_players:
actor_id = name_to_actor.get(player.name)
if actor_id is None:
logger.warning(
f"Could not find actor ID for {player.name} in report {report_code}"
)
continue
talent_data = client.query(
TALENT_IMPORT_CODE_QUERY,
{
"reportCode": report_code,
"fightIds": [player.fight_id],
"actorId": actor_id,
},
)
fights = talent_data["reportData"]["report"].get("fights", [])
if fights and fights[0].get("talentImportCode"):
player.talent_import_code = fights[0]["talentImportCode"]
logger.debug(f"Got talent code for {player.name}")
return players
```
### 3.10 `talent_aggregator.py` -- Determine Best Build
```python
from collections import Counter
from models import RankedPlayer, TalentBuild, SpecBuildResult
def aggregate_talent_builds(
players: list[RankedPlayer],
class_name: str,
spec_name: str,
content_type: str,
) -> SpecBuildResult:
"""
Determine the "best" talent build for a spec from ranked player data.
Strategy: Pure popularity vote among top players.
The talent import code used by the most top-ranked players wins.
This is intentionally simple -- the top players on WarcraftLogs are
already self-selected for performance, so the most popular build
among them is almost certainly viable and well-optimized.
"""
# Filter to players who have talent codes
players_with_talents = [p for p in players if p.talent_import_code]
if not players_with_talents:
return SpecBuildResult(
class_name=class_name,
spec_name=spec_name,
content_type=content_type,
best_build="",
build_count=0,
total_players_sampled=len(players),
all_builds=[],
)
# Count occurrences of each talent code
code_counter = Counter(p.talent_import_code for p in players_with_talents)
# Build detailed results
builds = []
for code, count in code_counter.most_common():
matching_players = [p for p in players_with_talents if p.talent_import_code == code]
builds.append(TalentBuild(
import_code=code,
count=count,
total_score=sum(p.amount for p in matching_players),
sample_players=[p.name for p in matching_players[:5]],
))
best = builds[0]
return SpecBuildResult(
class_name=class_name,
spec_name=spec_name,
content_type=content_type,
best_build=best.import_code,
build_count=best.count,
total_players_sampled=len(players),
all_builds=builds,
)
```
### 3.11 `lua_writer.py` -- Generate TalentData.lua
```python
import datetime
from models import SpecBuildResult
def write_talent_data_lua(
results: list[SpecBuildResult],
output_path: str,
addon_name: str = "AzerothsTopTalent",
) -> None:
"""Generate the TalentData.lua file consumed by the WoW addon."""
timestamp = datetime.datetime.utcnow().isoformat()
lines = []
lines.append(f"-- Generated by {addon_name} data pipeline")
lines.append(f"-- Timestamp: {timestamp}")
lines.append(f"-- DO NOT EDIT - This file is auto-generated")
lines.append("")
lines.append(f"local _, addon = ...")
lines.append("")
lines.append("addon.TalentData = {")
# Organize by class > spec > content_type
# Structure: TalentData["ClassName"]["SpecName"]["raid"|"mythicplus"]
by_class: dict[str, dict[str, dict[str, SpecBuildResult]]] = {}
for result in results:
by_class.setdefault(result.class_name, {})
by_class[result.class_name].setdefault(result.spec_name, {})
by_class[result.class_name][result.spec_name][result.content_type] = result
for class_name in sorted(by_class):
lines.append(f' ["{class_name}"] = {{')
for spec_name in sorted(by_class[class_name]):
lines.append(f' ["{spec_name}"] = {{')
for content_type in sorted(by_class[class_name][spec_name]):
result = by_class[class_name][spec_name][content_type]
lines.append(f' ["{content_type}"] = {{')
lines.append(f' talentCode = "{_escape_lua(result.best_build)}",')
lines.append(f' sampleSize = {result.total_players_sampled},')
lines.append(f' buildCount = {result.build_count},')
lines.append(f' popularity = {_popularity_pct(result)},')
lines.append(f' }},')
lines.append(f' }},')
lines.append(f' }},')
lines.append("}")
lines.append("")
lines.append(f'addon.TalentDataTimestamp = "{timestamp}"')
lines.append("")
with open(output_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
def _escape_lua(s: str) -> str:
"""Escape a string for Lua string literals."""
return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
def _popularity_pct(result: SpecBuildResult) -> str:
"""Calculate popularity as a percentage string."""
if result.total_players_sampled == 0:
return "0"
pct = round(result.build_count / result.total_players_sampled * 100, 1)
return str(pct)
```
### 3.12 `__main__.py` -- Orchestrator / Entry Point
```python
"""
AzerothsTopTalent Data Pipeline
Usage:
python -m data_pipeline
Environment variables:
WCL_CLIENT_ID - WarcraftLogs API client ID (required)
WCL_CLIENT_SECRET - WarcraftLogs API client secret (required)
WCL_RAID_ZONE_ID - Override raid zone ID (default: 42)
WCL_MPLUS_ZONE_ID - Override M+ zone ID (default: 43)
WCL_TOP_N - Number of top players to sample (default: 50)
WCL_OUTPUT_PATH - Output Lua file path (default: TalentData.lua)
"""
import logging
import sys
from config import Config
from auth import get_access_token
from api_client import WCLClient
from encounter_discovery import discover_zone_encounters
from rankings_fetcher import fetch_rankings_for_spec_encounter
from talent_extractor import fetch_talent_codes_from_reports
from talent_aggregator import aggregate_talent_builds
from lua_writer import write_talent_data_lua
from specs import ALL_SPECS
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("data_pipeline")
def main():
# 1. Load config
config = Config.from_env()
logger.info("Configuration loaded")
# 2. Authenticate
token = get_access_token(config.client_id, config.client_secret, config.token_url)
client = WCLClient(
api_url=config.api_url,
access_token=token,
request_delay=config.request_delay_seconds,
max_retries=config.max_retries,
)
logger.info("Authenticated with WarcraftLogs API")
# 3. Discover encounters
raid_zone = discover_zone_encounters(client, config.raid_zone_id)
mplus_zone = discover_zone_encounters(client, config.mplus_zone_id)
logger.info(f"Raid zone: {raid_zone.name} ({len(raid_zone.encounters)} encounters)")
logger.info(f"M+ zone: {mplus_zone.name} ({len(mplus_zone.encounters)} encounters)")
# 4. Fetch rankings for all specs across all encounters
all_results: list = []
content_configs = [
("raid", raid_zone, config.raid_difficulty),
("mythicplus", mplus_zone, 10), # difficulty=10 for M+ (keystone level)
]
for content_type, zone, difficulty in content_configs:
for spec in ALL_SPECS:
logger.info(f"Processing {spec.class_name}/{spec.spec_name} [{content_type}]")
all_players = []
for encounter in zone.encounters:
players = fetch_rankings_for_spec_encounter(
client, encounter, spec, difficulty, page=1,
)
all_players.extend(players)
# Fallback: fetch missing talent codes from reports
missing_count = sum(1 for p in all_players if p.talent_import_code is None)
if missing_count > 0:
logger.info(f" {missing_count} players missing talent codes, fetching from reports...")
all_players = fetch_talent_codes_from_reports(client, all_players)
# Aggregate
result = aggregate_talent_builds(
all_players, spec.class_name, spec.spec_name, content_type
)
all_results.append(result)
if result.best_build:
logger.info(
f" Best build: {result.best_build[:30]}... "
f"({result.build_count}/{result.total_players_sampled} players)"
)
else:
logger.warning(f" No talent data found!")
# 5. Write output
write_talent_data_lua(all_results, config.output_path)
logger.info(f"Wrote {config.output_path}")
logger.info("Done!")
if __name__ == "__main__":
main()
```
---
## 4. Lua Output Format
The generated `TalentData.lua` will have this structure:
```lua
-- Generated by AzerothsTopTalent data pipeline
-- Timestamp: 2025-02-15T12:00:00
-- DO NOT EDIT - This file is auto-generated
local _, addon = ...
addon.TalentData = {
["DeathKnight"] = {
["Blood"] = {
["mythicplus"] = {
talentCode = "BYQAAAAAAAAAAAAAAAAAAAAAAAAIJJJhkkkESSSSSSSSSSS...",
sampleSize = 50,
buildCount = 32,
popularity = 64.0,
},
["raid"] = {
talentCode = "BYQAAAAAAAAAAAAAAAAAAAAAAAAIJJJhkkkESSSSSSSSSSS...",
sampleSize = 50,
buildCount = 28,
popularity = 56.0,
},
},
["Frost"] = {
-- ...
},
-- ...
},
-- ... all 13 classes
}
addon.TalentDataTimestamp = "2025-02-15T12:00:00"
```
The addon Lua code will then read this via:
```lua
local talentCode = addon.TalentData[className][specName][contentType].talentCode
```
---
## 5. Data Flow Summary
```
[Environment Variables: WCL_CLIENT_ID, WCL_CLIENT_SECRET]
|
v
[OAuth2 Token Exchange]
|
v
[Discover Zone Encounters]
zone 42 (raid), zone 43 (M+)
|
v
[For each of 39 specs x 2 content types = 78 combinations]
|
v
[For each encounter in zone]
Query characterRankings with includeCombatantInfo=true
Extract talent codes from rankings response
|
v
[Fallback: Report-based lookup]
For players missing talent codes,
query report > fight > talentImportCode(actorID)
|
v
[Aggregate per spec+contentType]
Count talent code frequencies
Pick the most popular as "best build"
|
v
[Write TalentData.lua]
Structured Lua table: Class > Spec > ContentType > talentCode
```
---
## 6. API Call Budget Analysis
With 3,600 points/hour (free tier), the call budget is tight:
- 2 zone discovery queries = 2 calls
- Raid: 39 specs x ~8 encounters x 1 page = 312 calls
- M+: 39 specs x ~8 dungeons x 1 page = 312 calls
- Subtotal (Approach A): ~626 calls
- Report fallback (Approach B worst case): up to 50 players x 78 spec/content combos = 3,900 additional calls -- this would exceed the budget
**Mitigation strategies:**
1. Use `includeCombatantInfo: true` to get talent data inline (avoids report lookups entirely if the data is present)
2. Batch report queries: group players by report code so one report query covers multiple players
3. Add a disk cache (`cache.py`): cache API responses by query hash so reruns do not repeat calls
4. Add a `--spec` CLI flag to process only specific specs during development/debugging
5. If Approach B is needed, limit to top 20 players instead of 50 to stay within budget
---
## 7. "Best Build" Determination Algorithm
The algorithm is deliberately simple and transparent:
1. Collect the top N ranked players per spec per encounter for a given content type
2. Pool all players across encounters for that spec+content combo
3. Group by exact talent import code string (each unique string = one build)
4. Rank builds by frequency (how many top players use it)
5. The build with the highest frequency is the "best" build
**Why simple frequency wins over weighted scoring:**
- Top players on WarcraftLogs are already filtered by performance (they are the best performers)
- A build used by 30 of the top 50 players is more trustworthy than one used by 5 players who happen to have slightly higher DPS (those 5 might have unusual fight conditions, external buffs, etc.)
- This mirrors what sites like Archon and Subcreation do
- It avoids overfitting to a single outlier parse
**Potential enhancement (for later):** If two builds are very close in popularity (e.g., 25 vs 23 out of 50), provide both as "Build A" and "Build B" in the Lua data.
---
## 8. Error Handling Strategy
| Scenario | Handling |
|---|---|
| OAuth2 token failure | Fatal error with clear message about credentials |
| Zone/encounter not found | Fatal error suggesting zone ID may need updating |
| 429 Rate Limited | Exponential backoff (10s, 20s, 40s), then fail after 3 retries |
| Network timeout | Retry up to 3 times with backoff |
| GraphQL errors in response | Log error details, skip that query, continue |
| No rankings data for a spec | Log warning, output empty talent code for that spec |
| No talent codes found for any players | Log warning, output empty string |
| Missing environment variables | Immediate error with instructions |
---
## 9. Dependencies (Python Packages)
```
# requirements.txt
httpx>=0.27.0 # HTTP client (async-capable, modern alternative to requests)
python-dotenv>=1.0.0 # Load .env files for configuration
```
Only two external dependencies. We intentionally avoid:
- `gql`: Adds complexity (schema fetching, transport abstraction) that is unnecessary for a batch pipeline with known queries
- `requests`: `httpx` is a more modern alternative with the same API but better timeout handling
- `aiohttp`/`asyncio`: The pipeline is inherently sequential due to rate limiting, so async provides no benefit and adds complexity
---
## 10. Potential Challenges and Mitigations
**Challenge 1: `includeCombatantInfo` may not return talent import codes.**
The WCL API documentation describes `includeCombatantInfo` as including "detailed combatant info such as gear" -- it is unclear from the docs alone whether this includes talent strings. During development, the first thing to validate is what this flag actually returns. If it does not include talent codes, the report-based fallback (Approach B) is necessary.
**Challenge 2: Rate limit budget for 78 spec/content combinations.**
Each run requires ~626 API calls at minimum. At 1 call/second, this takes ~10 minutes. If report fallbacks are needed, it could exceed the hourly budget. Mitigations: caching, batching, reducing `top_n_players`.
**Challenge 3: WCL class/spec slugs must match exactly.**
The slug format (e.g., "DeathKnight" not "Death Knight", "BeastMastery" not "Beast Mastery") must be validated against the API. The `specs.py` file should be verified against actual API responses during initial development.
**Challenge 4: Zone/encounter IDs change each season.**
The pipeline is designed to discover encounters dynamically via `worldData > zone(id)`, but the zone IDs themselves (42, 43) must be updated when a new raid tier or M+ season launches. These are in `config.py` and overridable via environment variables.
**Challenge 5: M+ difficulty parameter.**
For M+ rankings, the `difficulty` parameter meaning may differ from raid. The M+ characterRankings might use `difficulty: 10` for M+10, or it might use a different convention. This needs validation against the actual API.
---
### Critical Files for Implementation
- `data_pipeline/api_client.py` - Core GraphQL client with rate limiting, retry logic, and auth header management; everything depends on this working correctly
- `data_pipeline/queries.py` - All GraphQL query strings; these must be validated against the live WCL API schema since the exact field availability (especially `talentImportCode` location) is the biggest unknown
- `data_pipeline/rankings_fetcher.py` - The main data extraction logic that parses the `characterRankings` JSON response and extracts talent codes; this is where the primary approach vs fallback decision happens
- `data_pipeline/lua_writer.py` - Generates the Lua output file that the addon consumes; the format must match exactly what the addon's Lua code expects to read
- `data_pipeline/specs.py` - Complete registry of all 39 specs with exact WCL API slug names; incorrect slugs will cause silent empty results
agentId: a516df5 (for resuming to continue this agent's work if needed)
<usage>total_tokens: 62363
tool_uses: 59
duration_ms: 4205777</usage>I now have a thorough understanding of all the APIs and patterns needed. Let me compile the complete architecture design.
---
# AzerothsTopTalent -- Complete Addon Architecture Design
## 1. File Structure
```
AzerothsTopTalent/
AzerothsTopTalent.toc -- Addon manifest
Core.lua -- Main addon initialization, event handling, state machine
TalentData.lua -- Pre-baked talent strings per spec per content type
ImportExport.lua -- Deserialize Blizzard talent strings into entry arrays
LoadoutManager.lua -- Create/update/apply loadouts via C_ClassTalents + C_Traits
ContentDetector.lua -- Detect M+ vs raid vs open world from events
PromptUI.lua -- StaticPopup-based confirmation dialog
Settings.lua -- Settings panel + SavedVariables management
```
**Rationale**: Seven files keeps concerns cleanly separated. Each file maps to a single responsibility. The split between `ImportExport.lua` (pure data transformation) and `LoadoutManager.lua` (stateful API interaction) is critical because import string parsing is complex enough to deserve its own file, and loadout management involves async event flows that are easier to reason about in isolation.
---
## 2. TOC File: `AzerothsTopTalent.toc`
```toc
## Interface: 120001
## Title: AzerothsTopTalent
## Notes: Auto-switches talents for M+ dungeons and raids
## Author: [Author]
## Version: 1.0.0
## SavedVariables: AzerothsTopTalentDB
## SavedVariablesPerCharacter: AzerothsTopTalentCharDB
## IconTexture: Interface\Icons\INV_Misc_Book_09
## Category: Combat
TalentData.lua
ImportExport.lua
LoadoutManager.lua
ContentDetector.lua
PromptUI.lua
Settings.lua
Core.lua
```
**Load order matters**: `TalentData.lua` first (pure data, no dependencies), then `ImportExport.lua` (pure functions), then `LoadoutManager.lua` (depends on ImportExport), then `ContentDetector.lua` (standalone event logic), then `PromptUI.lua` (standalone UI), then `Settings.lua` (standalone), and finally `Core.lua` last since it wires everything together.
`SavedVariables` (account-wide) stores global preferences. `SavedVariablesPerCharacter` stores per-character state like which loadout configs this character owns.
---
## 3. TalentData.lua -- Data File Structure
This file contains pre-baked talent import strings organized by specID and content type. The strings are the standard Blizzard base64-encoded talent strings (the same format used when you copy/paste talent builds from websites or in-game).
```lua
-- TalentData.lua
-- This file is regenerated by an external tool (scraper, manual curation, etc.)
-- Do NOT edit manually.
AzerothsTopTalent_TalentData = {
-- Indexed by specID (number)
-- Sub-indexed by content type string: "mythicplus" or "raid"
-- Example: Frost Mage (specID 64)
[64] = {
mythicplus = "CYGAAAAAAAAAAAAAAAAAAAAAAMzMDMzMDMGmZGMzYmZsMzsZGYGzMmZMLzMzYmZmBAAAAA",
raid = "CYGAAAAAAAAAAAAAAAAAAAAAAMzMDMzMDMGmZGMzYmZsMzsZGYGzMmZMLzMzYmZMBAAAAA",
},
-- Example: Holy Paladin (specID 65)
[65] = {
mythicplus = "CcEAAAAAAAAAAAAAAAAAAAAAAMjZmZYMzMGmhZGYmZmxAbmZmBsZmZGzMzwMzsZGAAAA",
raid = "CcEAAAAAAAAAAAAAAAAAAAAAAMjZmZYMzMGmhZGYmZmxAbmZmBsZmZGzMzwMzsYGAAAA",
},
-- ... entries for all 39 specs ...
}
-- Optional: Human-readable metadata for display purposes
AzerothsTopTalent_TalentDataMeta = {
version = "11.1.0-2025-02-15", -- patch + date of last update
source = "Archon/Subcreation", -- where the data came from
}
```
**Key design decisions**:
- Indexed by `specID` (not class+spec name) because specIDs are the canonical identifier used throughout the C_ClassTalents API. SpecIDs are stable across patches.
- Only two content types for now (`mythicplus`, `raid`). The table structure is extensible if we later add `pvp`, `delves`, etc.
- The strings are standard Blizzard talent export strings. This means we can source them from any website (Wowhead, Archon, Subcreation, icy-veins) or from the game itself.
- Separate global table (`AzerothsTopTalent_TalentData`) so the data file can be regenerated independently.
- A metadata table tracks when the data was last updated and from what source, so users/developers can verify freshness.
---
## 4. ImportExport.lua -- Talent String Deserialization
This module converts Blizzard's base64 talent export strings into the `ImportLoadoutEntryInfo[]` array needed by the `C_ClassTalents.ImportLoadout` API.
**Architecture approach**: We implement our own deserialization based on the format documented in Blizzard's `Blizzard_ClassTalentImportExport.lua` and proven by the SimulationCraft addon and TalentLoadoutManager addon. We do NOT depend on Blizzard's own mixin being loaded (it is part of the talent UI frame which may not be loaded).
```lua
-- Module table
local ATT = AzerothsTopTalent -- set in Core.lua
ATT.ImportExport = {}
local IE = ATT.ImportExport
```
### Binary format of talent strings
The import string is a base64-encoded binary stream with this structure:
**Header** (fixed size):
- 8 bits: serialization version (must equal 2)
- 16 bits: specID
- 128 bits: tree hash (16 bytes, used for validation)
**Per-node content** (one entry per node in the tree, in tree order):
- 1 bit: isNodeSelected
- If selected:
- 1 bit: isPartiallyRanked
- If partially ranked: 6 bits for ranksPurchased
- 1 bit: isChoiceNode
- If choice node: 2 bits for choiceIndex (0-based)
### Key functions to implement
```
IE.LOADOUT_SERIALIZATION_VERSION = 2
IE.BIT_WIDTH_VERSION = 8
IE.BIT_WIDTH_SPEC_ID = 16
IE.BIT_WIDTH_RANKS_PURCHASED = 6
IE.CUSTOM_BASE64_TABLE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
```
**`IE:CreateBitReader(importString)`** -- Decodes the base64 string into a bit array and returns a reader object with `:ReadBits(numBits)` method.
**`IE:ReadHeader(reader)`** -- Extracts version, specID, and treeHash from the header. Returns `(specID, treeHash)` or `nil, errorMessage` if version mismatch.
**`IE:ReadContent(reader, configID, treeID)`** -- Iterates over all nodes in the tree (from `C_Traits.GetTreeNodes(treeID)`), reading the selection state for each from the bit stream. Returns an `ImportLoadoutEntryInfo[]` array where each entry has `{nodeID, ranksPurchased, selectionEntryID}`.
**`IE:DeserializeImportString(importString)`** -- Public entry point. Validates and parses a talent import string. Returns `(specID, entries)` where entries is the `ImportLoadoutEntryInfo[]` array, or `(nil, errorMessage)` on failure.
**Implementation note**: The tree node order used by the serialization MUST match `C_Traits.GetTreeNodes(treeID)`. This is a Blizzard invariant -- the serialized bits correspond 1:1 with the nodes returned by that API, in the same order. The TalentLoadoutManager addon confirms this approach.
---
## 5. LoadoutManager.lua -- Loadout Lifecycle
This is the most complex module. It manages creating, updating, and applying talent loadouts through the Blizzard API.
### Design Decision: Two Strategies (with rationale for choosing Strategy B)
**Strategy A -- Use Blizzard config slots**:
- Call `C_ClassTalents.RequestNewConfig("ATT-M+")` to create a Blizzard-native loadout
- Call `C_ClassTalents.ImportLoadout(configID, entries, name)` to populate it
- Call `C_ClassTalents.LoadConfig(configID, true)` to apply it
**Strategy B -- Direct tree manipulation** (CHOSEN):
- Get the active configID via `C_ClassTalents.GetActiveConfigID()`
- Reset the tree via `C_Traits.ResetTree(configID, treeID)`
- Iterate entries, calling `C_Traits.PurchaseRank(configID, nodeID)` for regular nodes and `C_Traits.SetSelection(configID, nodeID, entryID)` for choice nodes
- Commit via `C_ClassTalents.CommitConfig(savedConfigID)`
**Why Strategy B**: Strategy A consumes one of the player's limited 10 loadout slots per class. Players already have loadouts for different content and may be near the limit. Strategy B applies directly to the current active config without consuming any additional slots. This is the same approach used by the TalentLoadoutManager addon (the most popular addon in this space). The downside is more code, but it avoids a real user-facing limitation.
**However**, we should support both. Strategy A is simpler and more robust (less likely to break with API changes). We use Strategy B by default, with a SavedVariables setting to fall back to Strategy A if the user prefers it.
### Core flow
```
LoadoutManager:ApplyTalentString(importString, contentType)
1. Parse the import string -> IE:DeserializeImportString(importString)
-> Returns (specID, entries[])
2. Verify specID matches player's current spec (PlayerUtil.GetCurrentSpecID or
GetSpecializationInfo(GetSpecialization()))
3. Check C_ClassTalents.CanChangeTalents() -- cannot change if in combat, etc.
4. Get activeConfigID = C_ClassTalents.GetActiveConfigID()
5. Get configInfo = C_Traits.GetConfigInfo(activeConfigID) -> treeID = configInfo.treeIDs[1]
6. Compare current talents to desired talents (optional optimization to skip if identical)
7. Apply via chosen strategy:
Strategy B (default):
a. C_Traits.ResetTree(activeConfigID, treeID)
b. For each entry in entries:
- Get nodeInfo = C_Traits.GetNodeInfo(activeConfigID, entry.nodeID)
- If nodeInfo.type == Enum.TraitNodeType.Selection (choice node):
C_Traits.SetSelection(activeConfigID, entry.nodeID, entry.selectionEntryID)
- Else:
for i = 1, entry.ranksPurchased do
C_Traits.PurchaseRank(activeConfigID, entry.nodeID)
end
c. Get savedConfigID = C_ClassTalents.GetLastSelectedSavedConfigID(specID) or pick first from GetConfigIDsBySpecID()
d. C_ClassTalents.CommitConfig(savedConfigID)
8. Listen for TRAIT_CONFIG_UPDATED to confirm success
9. Report result back to caller
```
### Comparing current talents to avoid unnecessary switches
```
LoadoutManager:GetCurrentTalentString()
1. activeConfigID = C_ClassTalents.GetActiveConfigID()
2. return C_Traits.GenerateImportString(activeConfigID)
```
If the current export string matches the desired import string, we skip the switch and optionally inform the user ("Already using the recommended build").
### State machine for async operations
The commit operation triggers a cast bar ("Changing Talents") so we need state tracking:
```
States:
IDLE -> APPLYING -> COMMITTED -> IDLE
(on error) -> FAILED -> IDLE
Events:
TRAIT_CONFIG_UPDATED -> confirm success, transition to IDLE
CONFIG_COMMIT_FAILED -> rollback via C_Traits.RollbackConfig(), transition to FAILED
```
---
## 6. ContentDetector.lua -- Content Detection
This module detects what content the player is entering and fires a callback when a talent switch should be offered.
### Events to register
| Event | Purpose | Payload |
|-------|---------|---------|
| `PLAYER_ENTERING_WORLD` | Initial load, zone changes | isInitialLogin, isReloadingUi |
| `CHALLENGE_MODE_START` | M+ dungeon started | mapID |
| `ZONE_CHANGED_NEW_AREA` | Zone transition (catch raid entry) | none |
| `ENCOUNTER_START` | Raid boss pull (backup trigger) | encounterID, encounterName, difficultyID, groupSize |
### Detection logic
```lua
ContentDetector:OnEvent(event, ...)
if event == "CHALLENGE_MODE_START" then
self:OnContentDetected("mythicplus")
return
end
if event == "ZONE_CHANGED_NEW_AREA" or event == "PLAYER_ENTERING_WORLD" then
local _, instanceType, difficultyID = GetInstanceInfo()
if instanceType == "party" and difficultyID == 8 then
-- Mythic Keystone dungeon (difficultyID 8)
-- Note: CHALLENGE_MODE_START is more reliable, but this catches
-- the case where we zone in before the key is started
self:OnContentDetected("mythicplus")
elseif instanceType == "raid" then
-- Any raid (normal=14, heroic=15, mythic=16, LFR=17)
self:OnContentDetected("raid")
end
return
end
if event == "ENCOUNTER_START" then
local _, _, difficultyID, groupSize = ...
-- Backup trigger: if we somehow missed zone detection
if difficultyID == 14 or difficultyID == 15 or difficultyID == 16 or difficultyID == 17 then
self:OnContentDetected("raid")
end
return
end
```
### Deduplication
We track `lastDetectedContent` and `lastDetectedTime` to avoid spamming the prompt. If the same content type was detected within the last 60 seconds, we skip. We also track `lastPromptedSpecContent` (a `specID.."-"..contentType` key) to avoid re-prompting after the user already accepted or dismissed for this session.
---
## 7. PromptUI.lua -- Confirmation Dialog
Uses WoW's built-in `StaticPopupDialogs` system for the confirmation prompt. This is the standard approach -- it integrates naturally with the game, respects the popup stacking system, and requires no XML.
```lua
StaticPopupDialogs["ATT_SWITCH_TALENTS"] = {
text = "AzerothsTopTalent\n\nSwitch to %s talents for %s (%s)?",
-- %s = content type name ("M+" or "Raid")
-- %s = spec name (e.g., "Frost")
-- %s = class name (e.g., "Mage")
button1 = "Switch",
button2 = "Cancel",
button3 = "Don't Ask Again",
OnAccept = function(self, data)
-- data = {contentType = "mythicplus", specID = 64, importString = "..."}
AzerothsTopTalent.LoadoutManager:ApplyTalentString(data.importString, data.contentType)
end,
OnCancel = function(self, data, reason)
-- User clicked Cancel; do nothing, but record that we prompted
end,
OnAlt = function(self, data)
-- "Don't Ask Again" -- save preference to suppress future prompts
AzerothsTopTalent.Settings:SetSuppressed(data.specID, data.contentType, true)
end,
timeout = 30, -- Auto-dismiss after 30 seconds
whileDead = false, -- Don't show when dead
hideOnEscape = true,
showAlert = false,
preferredIndex = 3, -- Avoid taint issues with first popup slot
}
```
**Show the prompt**:
```lua
function PromptUI:ShowSwitchPrompt(contentType, specID, importString)
local contentName = (contentType == "mythicplus") and "M+" or "Raid"
local _, specName, _, _, _, _, className = GetSpecializationInfoByID(specID)
local dialog = StaticPopup_Show("ATT_SWITCH_TALENTS", contentName, specName, className)
if dialog then
dialog.data = {
contentType = contentType,
specID = specID,
importString = importString,
}
end
end
```
### Combat check before showing
The prompt can be shown during combat (it is not a protected frame), but talents cannot be changed during combat. So if the user accepts while in combat, we queue the switch and apply it on `PLAYER_REGEN_ENABLED` (combat ends). The prompt itself shows immediately because the player should see it as soon as they zone in.
---
## 8. Settings.lua -- SavedVariables and Settings Panel
### SavedVariables Structure
**Account-wide** (`AzerothsTopTalentDB`):
```lua
AzerothsTopTalentDB = {
enabled = true, -- Master toggle
useBlizzardLoadoutSlots = false, -- Strategy A vs B (see LoadoutManager)
promptTimeout = 30, -- Seconds before auto-dismiss
suppressions = {
-- ["specID-contentType"] = true
-- e.g., ["64-mythicplus"] = true means "don't prompt for Frost Mage M+"
["64-mythicplus"] = true,
},
autoApply = false, -- If true, skip prompt and apply immediately
debugMode = false, -- Print debug messages to chat
}
```
**Per-character** (`AzerothsTopTalentCharDB`):
```lua
AzerothsTopTalentCharDB = {
lastApplied = {
-- ["contentType"] = { specID = 64, importString = "...", timestamp = 1700000000 }
mythicplus = { specID = 64, importString = "...", timestamp = 1700000000 },
},
-- Track loadout config IDs we created (for Strategy A cleanup)
ownedConfigIDs = {
-- ["ATT-M+"] = 12345,
-- ["ATT-Raid"] = 12346,
},
}
```
### Default values
```lua
local DEFAULTS = {
enabled = true,
useBlizzardLoadoutSlots = false,
promptTimeout = 30,
suppressions = {},
autoApply = false,
debugMode = false,
}
local CHAR_DEFAULTS = {
lastApplied = {},
ownedConfigIDs = {},
}
```
### Initialization
On `ADDON_LOADED` for "AzerothsTopTalent", merge defaults into saved variables (preserving existing values):
```lua
function Settings:Initialize()
if not AzerothsTopTalentDB then
AzerothsTopTalentDB = CopyTable(DEFAULTS)
else
-- Merge missing keys from defaults
for k, v in pairs(DEFAULTS) do
if AzerothsTopTalentDB[k] == nil then
AzerothsTopTalentDB[k] = v
end
end
end
self.db = AzerothsTopTalentDB
-- Same for per-character
...
end
```
### Settings Panel (using Settings API)
```lua
function Settings:CreatePanel()
local category = Settings.RegisterVerticalLayoutCategory("AzerothsTopTalent")
-- Master enable toggle
local enabledSetting = Settings.RegisterAddOnSetting(
category, "ATT_Enabled", "enabled",
self.db, Settings.VarType.Boolean, "Enable AzerothsTopTalent", true
)
Settings.CreateCheckbox(category, enabledSetting, "Toggle automatic talent switching prompts.")
-- Auto-apply toggle (skip confirmation)
local autoSetting = Settings.RegisterAddOnSetting(
category, "ATT_AutoApply", "autoApply",
self.db, Settings.VarType.Boolean, "Auto-apply without prompting", false
)
Settings.CreateCheckbox(category, autoSetting, "Apply talents immediately without showing a confirmation dialog.")
-- Use Blizzard loadout slots toggle
local slotsSetting = Settings.RegisterAddOnSetting(
category, "ATT_UseBlizzardSlots", "useBlizzardLoadoutSlots",
self.db, Settings.VarType.Boolean, "Use Blizzard loadout slots", false
)
Settings.CreateCheckbox(category, slotsSetting,
"Create named loadout slots (ATT-M+, ATT-Raid) instead of modifying the active config directly. Uses your limited loadout slots.")
Settings.RegisterAddOnCategory(category)
self.settingsCategory = category
end
```
Slash command `/att` opens the settings panel via `Settings.OpenToCategory(self.settingsCategory)` and also supports `/att reset` to clear suppressions.
---
## 9. Core.lua -- Main Orchestration
This is the "wiring" file that creates the addon namespace, initializes all modules, registers events, and connects the detection -> lookup -> prompt -> apply pipeline.
### Addon namespace setup
```lua
local addonName, ATT = ...
AzerothsTopTalent = ATT -- Global for SavedVariables access and inter-file communication
ATT.addonName = addonName
ATT.version = C_AddOns.GetAddOnMetadata(addonName, "Version")
```
### Initialization flow
```lua
-- Create the event frame
local eventFrame = CreateFrame("Frame")
eventFrame:RegisterEvent("ADDON_LOADED")
eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
eventFrame:RegisterEvent("TRAIT_CONFIG_LIST_UPDATED")
eventFrame:SetScript("OnEvent", function(self, event, ...)
if event == "ADDON_LOADED" and ... == addonName then
ATT.Settings:Initialize()
ATT.PromptUI:Initialize()
ATT.Settings:CreatePanel()
-- Do NOT initialize LoadoutManager yet -- need TRAIT_CONFIG_LIST_UPDATED first
return
end
if event == "TRAIT_CONFIG_LIST_UPDATED" then
-- Now the C_ClassTalents API is ready
ATT.LoadoutManager:Initialize()
ATT.ContentDetector:Initialize()
-- Register content detection events
eventFrame:RegisterEvent("CHALLENGE_MODE_START")
eventFrame:RegisterEvent("ZONE_CHANGED_NEW_AREA")
eventFrame:RegisterEvent("ENCOUNTER_START")
eventFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
self:UnregisterEvent("TRAIT_CONFIG_LIST_UPDATED")
-- Do an initial check in case we logged in inside relevant content
ATT.ContentDetector:CheckCurrentZone()
return
end
-- Delegate to ContentDetector for content events
ATT.ContentDetector:OnEvent(event, ...)
end)
```
### Main pipeline (ContentDetector callback)
```lua
function ATT:OnContentDetected(contentType)
-- 1. Guard: is addon enabled?
if not self.Settings:IsEnabled() then return end
-- 2. Get current spec
local specID = PlayerUtil.GetCurrentSpecID()
if not specID then return end
-- 3. Is this spec+content suppressed?
if self.Settings:IsSuppressed(specID, contentType) then return end
-- 4. Look up talent data
local talentData = AzerothsTopTalent_TalentData[specID]
if not talentData or not talentData[contentType] then
self:Debug("No talent data for specID=%d contentType=%s", specID, contentType)
return
end
local importString = talentData[contentType]
-- 5. Check if already using the recommended build
local currentString = self.LoadoutManager:GetCurrentTalentString()
if currentString == importString then
self:Debug("Already using recommended %s build", contentType)
return
end
-- 6. Check if we can change talents right now
if InCombatLockdown() then
-- Queue for after combat
self.pendingSwitch = {contentType = contentType, specID = specID, importString = importString}
self:Print("Will prompt for talent switch after combat ends.")
return
end
-- 7. Auto-apply or prompt
if self.Settings:IsAutoApply() then
self.LoadoutManager:ApplyTalentString(importString, contentType)
else
self.PromptUI:ShowSwitchPrompt(contentType, specID, importString)
end
end
```
### Combat queue handling
```lua
-- In the event handler:
if event == "PLAYER_REGEN_ENABLED" then
-- Combat ended, check for pending switch
if ATT.pendingSwitch then
local ps = ATT.pendingSwitch
ATT.pendingSwitch = nil
if ATT.Settings:IsAutoApply() then
ATT.LoadoutManager:ApplyTalentString(ps.importString, ps.contentType)
else
ATT.PromptUI:ShowSwitchPrompt(ps.contentType, ps.specID, ps.importString)
end
end
end
```
---
## 10. Edge Cases
### Already have correct talents
Handled by comparing `C_Traits.GenerateImportString(activeConfigID)` against the desired import string. If they match, skip silently (or print a debug message).
### Player is in combat
`InCombatLockdown()` check before any talent API calls. If in combat, queue the request and process on `PLAYER_REGEN_ENABLED`. The prompt itself can be shown (it is not a protected frame), but the actual talent switch must wait.
### Player is dead
`StaticPopupDialogs` has `whileDead = false`. If the player is dead, we defer the prompt. We could also check `UnitIsDeadOrGhost("player")` and queue similarly to combat.
### Talent API not ready
The `C_ClassTalents.GetConfigIDsBySpecID()` API only works after `TRAIT_CONFIG_LIST_UPDATED` fires. We gate all initialization on that event.
### No talent data for spec
If `AzerothsTopTalent_TalentData[specID]` or `AzerothsTopTalent_TalentData[specID][contentType]` is nil, do nothing. This is expected for newly added specs or content types not yet curated.
### Import string tree hash mismatch
After a patch changes the talent tree, old import strings become invalid (the tree hash in the header won't match). The `ImportExport:DeserializeImportString()` function should catch this gracefully and print a user-friendly message: "Talent data is outdated for [Spec Name]. Please update AzerothsTopTalent."
### Loadout slot limit (Strategy A only)
`C_ClassTalents.CanCreateNewConfig()` returns false if at the 10-slot limit. If we cannot create a new config, fall back to finding an existing "ATT-*" config to overwrite, or use Strategy B instead.
### Multiple prompts
If the player zones into M+ and we prompt, then the key starts and `CHALLENGE_MODE_START` fires, we should not prompt again. The deduplication logic in `ContentDetector` (tracking `lastPromptedSpecContent` per session) handles this.
### Spec changes mid-session
If the player changes spec inside an instance, we should re-check. Register for `ACTIVE_PLAYER_SPECIALIZATION_CHANGED` and re-run the detection check.
### Rested areas / tomes
Talent changes outside of rested areas require a Tome of the Still Mind (or equivalent). The `C_ClassTalents.CanChangeTalents()` API handles this check. If talents cannot be changed, we should inform the user rather than silently failing.
---
## 11. Event Flow Diagram
```
ADDON_LOADED
-> Settings:Initialize()
-> PromptUI:Initialize()
-> Settings:CreatePanel()
TRAIT_CONFIG_LIST_UPDATED
-> LoadoutManager:Initialize()
-> ContentDetector:Initialize()
-> Register content events
-> ContentDetector:CheckCurrentZone() (initial check)
CHALLENGE_MODE_START / ZONE_CHANGED_NEW_AREA / PLAYER_ENTERING_WORLD
-> ContentDetector:OnEvent()
-> ContentDetector:OnContentDetected(contentType)
-> ATT:OnContentDetected(contentType)
-> Check enabled, spec, suppressions
-> Look up talent string from TalentData
-> Compare with current talents
-> If in combat: queue for PLAYER_REGEN_ENABLED
-> If autoApply: LoadoutManager:ApplyTalentString()
-> Else: PromptUI:ShowSwitchPrompt()
User clicks "Switch":
-> LoadoutManager:ApplyTalentString(importString, contentType)
-> ImportExport:DeserializeImportString(importString) -> entries[]
-> C_Traits.ResetTree(activeConfigID, treeID)
-> For each entry: PurchaseRank / SetSelection
-> C_ClassTalents.CommitConfig(savedConfigID)
-> "Changing Talents" cast bar appears
TRAIT_CONFIG_UPDATED
-> LoadoutManager confirms success
-> Print("Talents switched to M+ build for Frost Mage")
CONFIG_COMMIT_FAILED
-> C_Traits.RollbackConfig(activeConfigID)
-> Print error to user
PLAYER_REGEN_ENABLED
-> Process any queued talent switch
```
---
## 12. Testing Strategy
Since WoW addons cannot be unit-tested in-game easily, the testing approach involves:
1. **Manual smoke test**: Log in, enter an M+ dungeon, verify prompt appears, accept, verify talents change.
2. **Edge case testing**: Log in while already inside an M+ dungeon. Change spec inside a raid. Be in combat when prompt would fire.
3. **Isolated function testing**: The `ImportExport` module is pure data transformation. It can potentially be tested outside WoW with a Lua interpreter by mocking the `C_Traits.GetTreeNodes` and `C_Traits.GetNodeInfo` calls. This is the most important module to test since the bit-level parsing is error-prone.
4. **Debug mode**: The `debugMode` SavedVariable enables verbose chat output of every step in the pipeline, making it easy to diagnose issues.
---
### Critical Files for Implementation
- `/mnt/dest-btrfs/home/jc/src/azerothtoptalent/ImportExport.lua` - "Most complex module: deserializes Blizzard talent strings into entry arrays via bit-level decoding. Error-prone and must match Blizzard's serialization format exactly."
- `/mnt/dest-btrfs/home/jc/src/azerothtoptalent/LoadoutManager.lua` - "Manages the async flow of resetting/purchasing/committing talents through C_Traits and C_ClassTalents APIs. Handles the state machine for commit success/failure."
- `/mnt/dest-btrfs/home/jc/src/azerothtoptalent/Core.lua` - "Orchestrates the full pipeline from event detection to talent application. Wires all modules together and handles combat queueing."
- `/mnt/dest-btrfs/home/jc/src/azerothtoptalent/TalentData.lua` - "Pre-baked talent strings for all 39 specs. Must be kept up-to-date with each patch. The data source that drives the entire addon."
- `/mnt/dest-btrfs/home/jc/src/azerothtoptalent/ContentDetector.lua` - "Event-driven content type detection with deduplication. Must correctly distinguish M+ (difficultyID 8) from raids (difficultyIDs 14-17) from other content."
agentId: a2c5dbe (for resuming to continue this agent's work if needed)
<usage>total_tokens: 52464
tool_uses: 52
duration_ms: 464153</usage>Excellent - I now have very thorough research and design from both agents. Let me synthesize this into a comprehensive plan. Let me review both designs and ensure alignment with the user's request before writing the final plan.
Key decisions confirmed:
Let me now write the comprehensive plan and ROADMAP.md. But wait - I'm in plan mode, so I can only write to the plan file.
Now I'll write out the plan file with all the implementation details and then exit plan mode so the roadmap can be created during the actual implementation phase.
Both design agents returned comprehensive plans. Let me update the task tracking and write the final plan.
Updated task #3 status
Updated task #4 status
Now let me write the comprehensive plan file.
Base directory for this skill: /mnt/dest-btrfs/home/jc/.claude/plugins/cache/claude-plugins-official/superpowers/4.3.0/skills/brainstorming
Help turn ideas into fully formed designs and specs through natural collaborative dialogue.
Start by understanding the current project context, then ask questions one at a time to refine the idea. Once you understand what you're building, present the design and get user approval.
<HARD-GATE> Do NOT invoke any implementation skill, write any code, scaffold any project, or take any implementation action until you have presented a design and the user has approved it. This applies to EVERY project regardless of perceived simplicity. </HARD-GATE>Every project goes through this process. A todo list, a single-function utility, a config change — all of them. "Simple" projects are where unexamined assumptions cause the most wasted work. The design can be short (a few sentences for truly simple projects), but you MUST present it and get approval.
You MUST create a task for each of these items and complete them in order:
docs/plans/YYYY-MM-DD-<topic>-design.md and commitThe terminal state is invoking writing-plans. Do NOT invoke frontend-design, mcp-builder, or any other implementation skill. The ONLY skill you invoke after brainstorming is writing-plans.
Understanding the idea:
Exploring approaches:
Presenting the design:
Documentation:
docs/plans/YYYY-MM-DD-<topic>-design.mdImplementation:
Updated task #4 status
Here's a summary of the plan:
AzerothsTopTalent — Two components:
Python data pipeline (data_pipeline/) — Authenticates with WarcraftLogs API v2 (GraphQL, OAuth2), queries top-ranked players' characterRankings for all 39 specs across M+ and raid content, extracts talent import codes, picks the most popular build per spec, and generates a TalentData.lua file indexed by specID.
WoW Lua addon (AzerothsTopTalent/) — 7 files: TalentData (data), ImportExport (deserialize talent strings), LoadoutManager (apply talents via C_Traits API without consuming loadout slots), ContentDetector (detect M+ via CHALLENGE_MODE_START, raids via zone/encounter events), PromptUI (StaticPopup confirmation dialog), Settings (SavedVariables + panel), Core (orchestration + combat queueing).
Key decisions baked in from your answers:
13 implementation steps, from scaffolding through to ROADMAP.md creation.
The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). To tell you how to proceed, the user said: Save the plan in plan.md
Now let me save this to plan.md in the project directory as requested.
Let me follow the brainstorming process. First, I need to explore the project context, then ask clarifying questions one at a time. Let me create tasks for the brainstorming checklist items and start exploring.
Since I'm in plan mode, I should focus on understanding the project and designing the approach. Let me start by exploring the current project state and researching the WarcraftLogs API and WoW addon patterns.
Plan saved to /mnt/dest-btrfs/home/jc/src/azerothtoptalent/plan.md. Ready for approval.
The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.
Let me start by exploring the project context and researching the key technical areas.
Task #1 created successfully: Explore project context