anvilbook 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. anvilbook-0.1.1/.gitignore +7 -0
  2. anvilbook-0.1.1/LICENSE +21 -0
  3. anvilbook-0.1.1/PKG-INFO +129 -0
  4. anvilbook-0.1.1/README.md +108 -0
  5. anvilbook-0.1.1/pyproject.toml +50 -0
  6. anvilbook-0.1.1/src/anvilbook/__init__.py +0 -0
  7. anvilbook-0.1.1/src/anvilbook/__main__.py +4 -0
  8. anvilbook-0.1.1/src/anvilbook/addon/AnvilbookExport/AnvilbookExport.lua +366 -0
  9. anvilbook-0.1.1/src/anvilbook/addon/AnvilbookExport/AnvilbookExport.toc +7 -0
  10. anvilbook-0.1.1/src/anvilbook/app.py +269 -0
  11. anvilbook-0.1.1/src/anvilbook/config.py +63 -0
  12. anvilbook-0.1.1/src/anvilbook/craft.py +168 -0
  13. anvilbook-0.1.1/src/anvilbook/discover.py +52 -0
  14. anvilbook-0.1.1/src/anvilbook/disenchant.py +104 -0
  15. anvilbook-0.1.1/src/anvilbook/importer.py +121 -0
  16. anvilbook-0.1.1/src/anvilbook/installer.py +32 -0
  17. anvilbook-0.1.1/src/anvilbook/items.py +15 -0
  18. anvilbook-0.1.1/src/anvilbook/luatable.py +89 -0
  19. anvilbook-0.1.1/src/anvilbook/plan.py +121 -0
  20. anvilbook-0.1.1/src/anvilbook/recipes.py +73 -0
  21. anvilbook-0.1.1/src/anvilbook/static/index.html +679 -0
  22. anvilbook-0.1.1/src/anvilbook/store.py +147 -0
  23. anvilbook-0.1.1/src/anvilbook/watcher.py +62 -0
  24. anvilbook-0.1.1/tests/addon_harness.lua +105 -0
  25. anvilbook-0.1.1/tests/addon_harness_modern.lua +186 -0
  26. anvilbook-0.1.1/tests/lua_fixture.py +47 -0
  27. anvilbook-0.1.1/tests/test_addon.py +18 -0
  28. anvilbook-0.1.1/tests/test_app.py +358 -0
  29. anvilbook-0.1.1/tests/test_config.py +28 -0
  30. anvilbook-0.1.1/tests/test_craft.py +131 -0
  31. anvilbook-0.1.1/tests/test_discover.py +34 -0
  32. anvilbook-0.1.1/tests/test_disenchant.py +42 -0
  33. anvilbook-0.1.1/tests/test_disenchant_records.py +42 -0
  34. anvilbook-0.1.1/tests/test_importer.py +79 -0
  35. anvilbook-0.1.1/tests/test_installer.py +49 -0
  36. anvilbook-0.1.1/tests/test_items.py +17 -0
  37. anvilbook-0.1.1/tests/test_luatable.py +59 -0
  38. anvilbook-0.1.1/tests/test_plan.py +71 -0
  39. anvilbook-0.1.1/tests/test_recipes.py +145 -0
  40. anvilbook-0.1.1/tests/test_store.py +66 -0
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ *.spec.bak
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mart Traagel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.5
2
+ Name: anvilbook
3
+ Version: 0.1.1
4
+ Summary: Auction house price history and crafting profit for WoW Forever (Classic beta)
5
+ Project-URL: Homepage, https://github.com/traagel/anvilbook
6
+ Project-URL: Issues, https://github.com/traagel/anvilbook/issues
7
+ Author: Mart Traagel
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: auction,auctionator,crafting,warcraft,wow
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Games/Entertainment
16
+ Requires-Python: >=3.12
17
+ Requires-Dist: cbor2>=5.6
18
+ Requires-Dist: fastapi>=0.136
19
+ Requires-Dist: uvicorn>=0.40
20
+ Description-Content-Type: text/markdown
21
+
22
+ # anvilbook
23
+
24
+ A local auction house and crafting ledger for **WoW Forever (Classic beta)**.
25
+
26
+ It reads the prices that the [Auctionator](https://www.curseforge.com/wow/addons/auctionator)
27
+ addon saves, keeps every scan as history, and tells you what is worth crafting with the
28
+ recipes your characters actually know.
29
+
30
+ Everything runs on your own computer. Nothing is uploaded anywhere.
31
+
32
+ ## What it does
33
+
34
+ - **Crafts**: for each recipe, the cheapest way to get every reagent (buy it, or craft it
35
+ from its own reagents), the profit, and the profit for each cast. Skill-up colors come
36
+ from the game.
37
+ - **Plan**: "I have 3g 43s, what do I buy?" It answers with a batch size, a shopping list
38
+ with quantities, the crafting steps, what comes from your bags, and the leftovers.
39
+ - **History**: the lowest price and the listed count of any item over all your scans.
40
+ - **Sell-through**: what sold between 2 scans, which shows real demand.
41
+ - **Disenchanting**: values a crafted item by its disenchant materials as well as its sale
42
+ price, and learns the real yields from the disenchants you do.
43
+
44
+ ## Install
45
+
46
+ You need [Auctionator](https://www.curseforge.com/wow/addons/auctionator) in game.
47
+
48
+ **Windows:** download `anvilbook.exe` from the
49
+ [releases page](https://github.com/traagel/anvilbook/releases) and run it. Your browser
50
+ opens on the app.
51
+
52
+ **Anything else, with [uv](https://docs.astral.sh/uv/):**
53
+
54
+ ```bash
55
+ uvx anvilbook
56
+ ```
57
+
58
+ **From source:**
59
+
60
+ ```bash
61
+ git clone https://github.com/traagel/anvilbook
62
+ cd anvilbook
63
+ uv run anvilbook
64
+ ```
65
+
66
+ ## First run
67
+
68
+ 1. Point anvilbook at your game. Paste the path to `Auctionator.lua`, or press **Look for my game
69
+ folder** and pick one of the results. It only searches your disk when you press that button,
70
+ and it never sends anything anywhere.
71
+ 2. Press **Install addon**. That copies the small `AnvilbookExport` addon into the game.
72
+ 3. Start WoW. Open each profession window for a few seconds, then type `/reload`.
73
+ 4. Scan the auction house with Auctionator, then type `/reload` again.
74
+
75
+ Step 3 gives anvilbook your recipes and skill levels. Step 4 gives it prices. Repeat step 4
76
+ whenever you want fresh prices; the app imports the file within 5 seconds of each `/reload`.
77
+
78
+ ## Why `/reload`
79
+
80
+ WoW writes addon data to disk only when you log out, disconnect, or type `/reload`. No addon
81
+ can write files at another time, so `/reload` is how the data reaches anvilbook.
82
+
83
+ ## Settings
84
+
85
+ The Settings tab holds the auction cut, the cast time, filters, the disenchant table, and the
86
+ file paths. A small config file also exists for values needed before the app starts:
87
+
88
+ `~/.config/anvilbook/config.toml` (Windows: `%APPDATA%\anvilbook\config.toml`)
89
+
90
+ ```toml
91
+ savedvariables_path = "/path/to/WTF/Account/12345#1/SavedVariables/Auctionator.lua"
92
+ port = 8765
93
+ open_browser = true
94
+ ```
95
+
96
+ Data lives in `~/.local/share/anvilbook/` (Windows: `%APPDATA%\anvilbook\`): the price
97
+ history database and a cached copy of the item database.
98
+
99
+ ## Known limits
100
+
101
+ - Built for **WoW Forever beta, client 1.60.1**, with an **English** client.
102
+ - Prices come from the lowest listing of each item, not the full price ladder, so a large
103
+ buy costs more than the estimate.
104
+ - Disenchant yields start as estimates, and get replaced by your measured results.
105
+ - On Forever, Auctionator loses its own price database on each game load. anvilbook imports
106
+ each save before that happens, which is a good reason to keep it running.
107
+
108
+ ## Development
109
+
110
+ ```bash
111
+ uv run pytest # 98 tests
112
+ luajit tests/addon_harness.lua src/anvilbook/addon/AnvilbookExport/AnvilbookExport.lua
113
+ luajit tests/addon_harness_modern.lua src/anvilbook/addon/AnvilbookExport/AnvilbookExport.lua
114
+ ```
115
+
116
+ The addon harnesses stub the WoW API, so they need [LuaJIT](https://luajit.org/) but not the
117
+ game.
118
+
119
+ ## Credits
120
+
121
+ - Item and recipe data: [nexus-devs/wow-classic-items](https://github.com/nexus-devs/wow-classic-items) (MIT)
122
+ - Charts: [uPlot](https://github.com/leeoniya/uPlot) (MIT)
123
+ - Prices: the [Auctionator](https://www.curseforge.com/wow/addons/auctionator) addon
124
+
125
+ anvilbook is not affiliated with Blizzard Entertainment.
126
+
127
+ ## Licence
128
+
129
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,108 @@
1
+ # anvilbook
2
+
3
+ A local auction house and crafting ledger for **WoW Forever (Classic beta)**.
4
+
5
+ It reads the prices that the [Auctionator](https://www.curseforge.com/wow/addons/auctionator)
6
+ addon saves, keeps every scan as history, and tells you what is worth crafting with the
7
+ recipes your characters actually know.
8
+
9
+ Everything runs on your own computer. Nothing is uploaded anywhere.
10
+
11
+ ## What it does
12
+
13
+ - **Crafts**: for each recipe, the cheapest way to get every reagent (buy it, or craft it
14
+ from its own reagents), the profit, and the profit for each cast. Skill-up colors come
15
+ from the game.
16
+ - **Plan**: "I have 3g 43s, what do I buy?" It answers with a batch size, a shopping list
17
+ with quantities, the crafting steps, what comes from your bags, and the leftovers.
18
+ - **History**: the lowest price and the listed count of any item over all your scans.
19
+ - **Sell-through**: what sold between 2 scans, which shows real demand.
20
+ - **Disenchanting**: values a crafted item by its disenchant materials as well as its sale
21
+ price, and learns the real yields from the disenchants you do.
22
+
23
+ ## Install
24
+
25
+ You need [Auctionator](https://www.curseforge.com/wow/addons/auctionator) in game.
26
+
27
+ **Windows:** download `anvilbook.exe` from the
28
+ [releases page](https://github.com/traagel/anvilbook/releases) and run it. Your browser
29
+ opens on the app.
30
+
31
+ **Anything else, with [uv](https://docs.astral.sh/uv/):**
32
+
33
+ ```bash
34
+ uvx anvilbook
35
+ ```
36
+
37
+ **From source:**
38
+
39
+ ```bash
40
+ git clone https://github.com/traagel/anvilbook
41
+ cd anvilbook
42
+ uv run anvilbook
43
+ ```
44
+
45
+ ## First run
46
+
47
+ 1. Point anvilbook at your game. Paste the path to `Auctionator.lua`, or press **Look for my game
48
+ folder** and pick one of the results. It only searches your disk when you press that button,
49
+ and it never sends anything anywhere.
50
+ 2. Press **Install addon**. That copies the small `AnvilbookExport` addon into the game.
51
+ 3. Start WoW. Open each profession window for a few seconds, then type `/reload`.
52
+ 4. Scan the auction house with Auctionator, then type `/reload` again.
53
+
54
+ Step 3 gives anvilbook your recipes and skill levels. Step 4 gives it prices. Repeat step 4
55
+ whenever you want fresh prices; the app imports the file within 5 seconds of each `/reload`.
56
+
57
+ ## Why `/reload`
58
+
59
+ WoW writes addon data to disk only when you log out, disconnect, or type `/reload`. No addon
60
+ can write files at another time, so `/reload` is how the data reaches anvilbook.
61
+
62
+ ## Settings
63
+
64
+ The Settings tab holds the auction cut, the cast time, filters, the disenchant table, and the
65
+ file paths. A small config file also exists for values needed before the app starts:
66
+
67
+ `~/.config/anvilbook/config.toml` (Windows: `%APPDATA%\anvilbook\config.toml`)
68
+
69
+ ```toml
70
+ savedvariables_path = "/path/to/WTF/Account/12345#1/SavedVariables/Auctionator.lua"
71
+ port = 8765
72
+ open_browser = true
73
+ ```
74
+
75
+ Data lives in `~/.local/share/anvilbook/` (Windows: `%APPDATA%\anvilbook\`): the price
76
+ history database and a cached copy of the item database.
77
+
78
+ ## Known limits
79
+
80
+ - Built for **WoW Forever beta, client 1.60.1**, with an **English** client.
81
+ - Prices come from the lowest listing of each item, not the full price ladder, so a large
82
+ buy costs more than the estimate.
83
+ - Disenchant yields start as estimates, and get replaced by your measured results.
84
+ - On Forever, Auctionator loses its own price database on each game load. anvilbook imports
85
+ each save before that happens, which is a good reason to keep it running.
86
+
87
+ ## Development
88
+
89
+ ```bash
90
+ uv run pytest # 98 tests
91
+ luajit tests/addon_harness.lua src/anvilbook/addon/AnvilbookExport/AnvilbookExport.lua
92
+ luajit tests/addon_harness_modern.lua src/anvilbook/addon/AnvilbookExport/AnvilbookExport.lua
93
+ ```
94
+
95
+ The addon harnesses stub the WoW API, so they need [LuaJIT](https://luajit.org/) but not the
96
+ game.
97
+
98
+ ## Credits
99
+
100
+ - Item and recipe data: [nexus-devs/wow-classic-items](https://github.com/nexus-devs/wow-classic-items) (MIT)
101
+ - Charts: [uPlot](https://github.com/leeoniya/uPlot) (MIT)
102
+ - Prices: the [Auctionator](https://www.curseforge.com/wow/addons/auctionator) addon
103
+
104
+ anvilbook is not affiliated with Blizzard Entertainment.
105
+
106
+ ## Licence
107
+
108
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "anvilbook"
3
+ version = "0.1.1"
4
+ description = "Auction house price history and crafting profit for WoW Forever (Classic beta)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Mart Traagel" }]
10
+ keywords = ["wow", "warcraft", "auction", "crafting", "auctionator"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Environment :: Web Environment",
14
+ "Intended Audience :: End Users/Desktop",
15
+ "Programming Language :: Python :: 3",
16
+ "Topic :: Games/Entertainment",
17
+ ]
18
+ dependencies = [
19
+ "fastapi>=0.136",
20
+ "uvicorn>=0.40",
21
+ "cbor2>=5.6",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/traagel/anvilbook"
26
+ Issues = "https://github.com/traagel/anvilbook/issues"
27
+
28
+ [project.scripts]
29
+ anvilbook = "anvilbook.app:run"
30
+
31
+ [dependency-groups]
32
+ dev = [
33
+ "pytest>=8",
34
+ "httpx>=0.28",
35
+ "pyinstaller>=6.11",
36
+ ]
37
+
38
+ [build-system]
39
+ requires = ["hatchling"]
40
+ build-backend = "hatchling.build"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/anvilbook"]
44
+
45
+ [tool.hatch.build.targets.sdist]
46
+ include = ["src", "tests", "README.md", "LICENSE"]
47
+
48
+ [tool.pyright]
49
+ venvPath = "."
50
+ venv = ".venv"
File without changes
@@ -0,0 +1,4 @@
1
+ from anvilbook.app import run
2
+
3
+ if __name__ == '__main__':
4
+ run()
@@ -0,0 +1,366 @@
1
+ -- Bumped when a fix makes older saved data wrong; it is then thrown away.
2
+ local VERSION = 5
3
+ local RETRIES = 5
4
+
5
+ local frame = CreateFrame("Frame")
6
+ local busy = false
7
+ local lastSeen
8
+ local reported = {}
9
+
10
+ local DIFFICULTY = {}
11
+ if Enum and Enum.TradeskillRelativeDifficulty then
12
+ for name, value in pairs(Enum.TradeskillRelativeDifficulty) do
13
+ DIFFICULTY[value] = name:lower()
14
+ end
15
+ end
16
+
17
+ local function itemId(link)
18
+ return link and tonumber(link:match("item:(%d+)"))
19
+ end
20
+
21
+ local function itemName(id)
22
+ if C_Item and C_Item.GetItemNameByID then
23
+ return C_Item.GetItemNameByID(id)
24
+ end
25
+ if GetItemInfo then
26
+ return (GetItemInfo(id))
27
+ end
28
+ end
29
+
30
+ -- GetAllRecipeIDs also returns other professions' recipes, so recipes are kept only
31
+ -- when their category belongs to the open profession.
32
+ local function categorySet(api)
33
+ if not api.GetCategories then
34
+ return nil
35
+ end
36
+ local ok, top = pcall(api.GetCategories)
37
+ if not ok or type(top) ~= "table" or #top == 0 then
38
+ return nil
39
+ end
40
+ local set, pending, count = {}, {}, 0
41
+ for _, id in ipairs(top) do
42
+ pending[#pending + 1] = id
43
+ end
44
+ while #pending > 0 do
45
+ local id = table.remove(pending)
46
+ if not set[id] then
47
+ set[id] = true
48
+ count = count + 1
49
+ if api.GetSubCategories then
50
+ local okSub, subs = pcall(api.GetSubCategories, id)
51
+ if okSub and type(subs) == "table" then
52
+ for _, sub in ipairs(subs) do
53
+ pending[#pending + 1] = sub
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ return set, count
60
+ end
61
+
62
+ -- Forever runs the retail professions UI, so recipes come from C_TradeSkillUI.
63
+ local function modernRecipes(db)
64
+ local api = C_TradeSkillUI
65
+ local info = api.GetBaseProfessionInfo and api.GetBaseProfessionInfo()
66
+ local profession = info and (info.professionName or info.parentProfessionName)
67
+ if not profession or profession == "" then
68
+ return nil
69
+ end
70
+ local all = api.GetAllRecipeIDs and api.GetAllRecipeIDs() or {}
71
+ local categories, categoryCount = categorySet(api)
72
+ local filtered = api.GetFilteredRecipeIDs and select(2, pcall(api.GetFilteredRecipeIDs)) or nil
73
+ filtered = type(filtered) == "table" and #filtered > 0 and filtered or nil
74
+ local debug = {api = "modern", recipeCount = #all, professionInfo = info, categories = categoryCount,
75
+ filteredCount = filtered and #filtered}
76
+ db.debug = debug
77
+
78
+ -- GetAllRecipeIDs returns every profession's recipes, so the open profession has to be
79
+ -- told apart by the window's own list, or by its categories.
80
+ local ids = filtered or all
81
+ debug.source = filtered and "filtered" or "categories"
82
+ if not filtered and not categories then
83
+ debug.skipped = "cannot tell which recipes belong to this profession"
84
+ return nil
85
+ end
86
+
87
+ -- The open profession changes before its recipe list does, so a single read can mix
88
+ -- one profession's name with another's recipes. Only a repeated reading is trusted.
89
+ local seen = table.concat({info.professionID or profession, #ids, ids[1] or 0, ids[#ids] or 0}, ":")
90
+ if seen ~= lastSeen then
91
+ lastSeen = seen
92
+ debug.skipped = "waiting for the recipe list to settle"
93
+ return nil
94
+ end
95
+ local basic = Enum and Enum.CraftingReagentType and Enum.CraftingReagentType.Basic
96
+ local recipes, recorded = {}, 0
97
+ for _, recipeID in ipairs(ids) do
98
+ local recipe = api.GetRecipeInfo(recipeID)
99
+ local mine = recipe and (filtered or (recipe.categoryID and categories[recipe.categoryID]))
100
+ local schematic = mine and recipe.learned and api.GetRecipeSchematic(recipeID, false)
101
+ local outputId = schematic and schematic.outputItemID
102
+ if outputId and outputId ~= 0 then
103
+ local reagents = {}
104
+ for _, slot in ipairs(schematic.reagentSlotSchematics or {}) do
105
+ local first = slot.reagents and slot.reagents[1]
106
+ -- Optional and finishing reagents are not part of the base cost.
107
+ if first and first.itemID and (not basic or slot.reagentType == basic) then
108
+ reagents[#reagents + 1] = {id = first.itemID, count = slot.quantityRequired or 1,
109
+ name = itemName(first.itemID)}
110
+ end
111
+ end
112
+ recipes[outputId] = {name = recipe.name or schematic.name, minMade = schematic.quantityMin or 1,
113
+ maxMade = schematic.quantityMax or 1, difficulty = DIFFICULTY[recipe.relativeDifficulty],
114
+ reagents = reagents}
115
+ recorded = recorded + 1
116
+ if not debug.sample then
117
+ debug.sample = {recipeInfo = recipe, schematic = schematic}
118
+ end
119
+ end
120
+ end
121
+ return profession, info.skillLevel, info.maxSkillLevel, recipes, recorded, true
122
+ end
123
+
124
+ local function classicRecipes(db, expand)
125
+ local profession, rank, maxRank = GetTradeSkillLine()
126
+ if not profession or profession == "UNKNOWN" then
127
+ return nil
128
+ end
129
+ -- Collapsed headers hide their recipes from GetTradeSkillInfo.
130
+ if expand and ExpandTradeSkillSubClass then
131
+ ExpandTradeSkillSubClass(0)
132
+ end
133
+ db.debug = {api = "classic", recipeCount = GetNumTradeSkills()}
134
+ local recipes, recorded = {}, 0
135
+ for i = 1, GetNumTradeSkills() do
136
+ local name, kind = GetTradeSkillInfo(i)
137
+ local id = kind ~= "header" and itemId(GetTradeSkillItemLink(i))
138
+ if id then
139
+ local reagents = {}
140
+ for r = 1, GetTradeSkillNumReagents(i) do
141
+ local reagentName, _, count = GetTradeSkillReagentInfo(i, r)
142
+ local reagentId = itemId(GetTradeSkillReagentItemLink(i, r))
143
+ -- Links are nil until the client caches the item; a retry fills them in.
144
+ if not reagentId or not count then
145
+ reagents = nil
146
+ break
147
+ end
148
+ reagents[r] = {id = reagentId, count = count, name = reagentName}
149
+ end
150
+ if reagents then
151
+ local minMade, maxMade = GetTradeSkillNumMade(i)
152
+ recipes[id] = {name = name, minMade = minMade, maxMade = maxMade, difficulty = kind, reagents = reagents}
153
+ recorded = recorded + 1
154
+ end
155
+ end
156
+ end
157
+ return profession, rank, maxRank, recipes, recorded
158
+ end
159
+
160
+ local function record(db, expand)
161
+ local profession, rank, maxRank, recipes, recorded, verified
162
+ if C_TradeSkillUI and C_TradeSkillUI.GetAllRecipeIDs then
163
+ profession, rank, maxRank, recipes, recorded, verified = modernRecipes(db)
164
+ else
165
+ profession, rank, maxRank, recipes, recorded = classicRecipes(db, expand)
166
+ end
167
+ if not profession then
168
+ return nil
169
+ end
170
+
171
+ if db.version ~= VERSION then
172
+ db.version, db.characters = VERSION, nil
173
+ end
174
+ db.characters = db.characters or {}
175
+ local key = UnitName("player") .. " - " .. GetRealmName()
176
+ local char = db.characters[key] or {professions = {}}
177
+ db.characters[key] = char
178
+ local prof = char.professions[profession] or {recipes = {}}
179
+ char.professions[profession] = prof
180
+ prof.rank, prof.maxRank = rank, maxRank
181
+ prof.updated = time()
182
+ char.updated = prof.updated
183
+ -- A verified list is complete, so it also clears recipes saved by an earlier version.
184
+ if verified then
185
+ prof.recipes = recipes
186
+ else
187
+ for id, recipe in pairs(recipes) do
188
+ prof.recipes[id] = recipe
189
+ end
190
+ end
191
+ return profession, recorded
192
+ end
193
+
194
+ local function run(event, attempt)
195
+ -- Expanding headers fires an update event.
196
+ if busy then
197
+ return
198
+ end
199
+ busy = true
200
+ AnvilbookExportDB = AnvilbookExportDB or {}
201
+ local db = AnvilbookExportDB
202
+ -- Script errors are hidden by default, so the saved file is the only place to see what happened.
203
+ db.lastEvent = {event = event, time = time()}
204
+ local ok, profession, recorded = pcall(record, db, event == "TRADE_SKILL_SHOW")
205
+ busy = false
206
+ if not ok then
207
+ db.lastError = tostring(profession)
208
+ geterrorhandler()(profession)
209
+ return
210
+ end
211
+ db.lastError = nil
212
+ if profession and reported[profession] ~= recorded then
213
+ reported[profession] = recorded
214
+ print(("Anvilbook: %d %s recipes recorded"):format(recorded, profession))
215
+ end
216
+ -- Item data and the recipe list arrive late, and the client may have no update event.
217
+ if C_Timer and (attempt or 0) < RETRIES then
218
+ C_Timer.After(1, function() run("RETRY", (attempt or 0) + 1) end)
219
+ end
220
+ end
221
+
222
+ local DISENCHANT_SPELL = 13262
223
+ local DISENCHANT_LOG_LIMIT = 500
224
+ local KINDS = {[2] = "Weapon", [4] = "Armor"}
225
+ local pendingDisenchant
226
+
227
+ local function bagItem(name)
228
+ local containers = C_Container or _G
229
+ local numSlots = containers.GetContainerNumSlots or GetContainerNumSlots
230
+ local itemIdAt = containers.GetContainerItemID or GetContainerItemID
231
+ if not numSlots or not itemIdAt then
232
+ return nil
233
+ end
234
+ for bag = 0, 4 do
235
+ for slot = 1, (numSlots(bag) or 0) do
236
+ local id = itemIdAt(bag, slot)
237
+ local info = C_Item and C_Item.GetItemInfo or GetItemInfo
238
+ local itemName, quality, itemLevel
239
+ if id and info then
240
+ -- An `and` chain would keep only the first return value.
241
+ itemName, _, quality, itemLevel = info(id)
242
+ end
243
+ if itemName == name then
244
+ local classID = select(6, C_Item.GetItemInfoInstant(id))
245
+ return {id = id, name = itemName, quality = quality, itemLevel = itemLevel, kind = KINDS[classID]}
246
+ end
247
+ end
248
+ end
249
+ end
250
+
251
+ local function character()
252
+ AnvilbookExportDB = AnvilbookExportDB or {}
253
+ local db = AnvilbookExportDB
254
+ db.characters = db.characters or {}
255
+ local key = UnitName("player") .. " - " .. GetRealmName()
256
+ db.characters[key] = db.characters[key] or {professions = {}}
257
+ return db.characters[key]
258
+ end
259
+
260
+ -- The shopping list subtracts what the character already carries.
261
+ local function recordBags()
262
+ local containers = C_Container or _G
263
+ local numSlots = containers.GetContainerNumSlots or GetContainerNumSlots
264
+ local slotInfo = containers.GetContainerItemInfo
265
+ local itemIdAt = containers.GetContainerItemID or GetContainerItemID
266
+ if not numSlots then
267
+ return
268
+ end
269
+ local bags = {}
270
+ for bag = 0, 4 do
271
+ for slot = 1, (numSlots(bag) or 0) do
272
+ local id, count
273
+ if slotInfo then
274
+ local info = slotInfo(bag, slot)
275
+ id, count = info and info.itemID, info and info.stackCount
276
+ elseif itemIdAt then
277
+ id, count = itemIdAt(bag, slot), select(2, GetContainerItemInfo(bag, slot))
278
+ end
279
+ if id then
280
+ bags[id] = (bags[id] or 0) + (count or 1)
281
+ end
282
+ end
283
+ end
284
+ local char = character()
285
+ char.bags = bags
286
+ char.bagsUpdated = time()
287
+ -- Bags change while playing, which is what "last played" means.
288
+ char.updated = char.bagsUpdated
289
+ end
290
+
291
+ local function recordDisenchant()
292
+ local item = pendingDisenchant
293
+ pendingDisenchant = nil
294
+ if not item then
295
+ return
296
+ end
297
+ local mats = {}
298
+ for slot = 1, (GetNumLootItems and GetNumLootItems() or 0) do
299
+ local link = GetLootSlotLink and GetLootSlotLink(slot)
300
+ local id = itemId(link)
301
+ local count = select(3, GetLootSlotInfo(slot))
302
+ if id then
303
+ mats[#mats + 1] = {id = id, count = count or 1}
304
+ end
305
+ end
306
+ AnvilbookExportDB = AnvilbookExportDB or {}
307
+ local log = AnvilbookExportDB.disenchants or {}
308
+ AnvilbookExportDB.disenchants = log
309
+ log[#log + 1] = {time = time(), item = item, mats = mats}
310
+ while #log > DISENCHANT_LOG_LIMIT do
311
+ table.remove(log, 1)
312
+ end
313
+ end
314
+
315
+ -- Reports what the client's API returns, because it differs from retail and from classic.
316
+ local function probe()
317
+ local api = C_TradeSkillUI or {}
318
+ local info = api.GetBaseProfessionInfo and api.GetBaseProfessionInfo()
319
+ local all = api.GetAllRecipeIDs and api.GetAllRecipeIDs() or {}
320
+ local okFiltered, filtered = pcall(api.GetFilteredRecipeIDs or function() end)
321
+ filtered = okFiltered and type(filtered) == "table" and filtered or nil
322
+ local _, categoryCount = categorySet(api)
323
+ local first = (filtered or all)[1]
324
+ local recipe = first and api.GetRecipeInfo and api.GetRecipeInfo(first)
325
+ local out = {
326
+ profession = info and info.professionName,
327
+ professionID = info and info.professionID,
328
+ allCount = #all,
329
+ filteredCount = filtered and #filtered,
330
+ categoryCount = categoryCount,
331
+ firstRecipe = recipe,
332
+ firstCategoryInfo = recipe and recipe.categoryID and api.GetCategoryInfo and api.GetCategoryInfo(recipe.categoryID),
333
+ }
334
+ AnvilbookExportDB = AnvilbookExportDB or {}
335
+ AnvilbookExportDB.probe = out
336
+ print(("Anvilbook probe: profession=%s all=%d filtered=%s categories=%s first=%s"):format(
337
+ tostring(out.profession), out.allCount, tostring(out.filteredCount), tostring(out.categoryCount),
338
+ tostring(recipe and recipe.name)))
339
+ end
340
+
341
+ SLASH_ANVILBOOK1 = "/anvilbook"
342
+ SlashCmdList["ANVILBOOK"] = probe
343
+
344
+ frame:SetScript("OnEvent", function(_, event, ...)
345
+ if event == "UNIT_SPELLCAST_SENT" then
346
+ local unit, target, _, spellID = ...
347
+ if unit == "player" and spellID == DISENCHANT_SPELL then
348
+ pendingDisenchant = bagItem(target)
349
+ end
350
+ return
351
+ end
352
+ if event == "LOOT_OPENED" or event == "BAG_UPDATE_DELAYED" then
353
+ local ok, err = pcall(event == "LOOT_OPENED" and recordDisenchant or recordBags)
354
+ if not ok then
355
+ geterrorhandler()(err)
356
+ end
357
+ return
358
+ end
359
+ run(event)
360
+ end)
361
+ frame:RegisterEvent("TRADE_SKILL_SHOW")
362
+ -- Registering an event this client does not know raises an error.
363
+ for _, event in ipairs({"TRADE_SKILL_UPDATE", "TRADE_SKILL_LIST_UPDATE", "TRADE_SKILL_DATA_SOURCE_CHANGED",
364
+ "UNIT_SPELLCAST_SENT", "LOOT_OPENED", "BAG_UPDATE_DELAYED"}) do
365
+ pcall(frame.RegisterEvent, frame, event)
366
+ end