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.
- anvilbook-0.1.1/.gitignore +7 -0
- anvilbook-0.1.1/LICENSE +21 -0
- anvilbook-0.1.1/PKG-INFO +129 -0
- anvilbook-0.1.1/README.md +108 -0
- anvilbook-0.1.1/pyproject.toml +50 -0
- anvilbook-0.1.1/src/anvilbook/__init__.py +0 -0
- anvilbook-0.1.1/src/anvilbook/__main__.py +4 -0
- anvilbook-0.1.1/src/anvilbook/addon/AnvilbookExport/AnvilbookExport.lua +366 -0
- anvilbook-0.1.1/src/anvilbook/addon/AnvilbookExport/AnvilbookExport.toc +7 -0
- anvilbook-0.1.1/src/anvilbook/app.py +269 -0
- anvilbook-0.1.1/src/anvilbook/config.py +63 -0
- anvilbook-0.1.1/src/anvilbook/craft.py +168 -0
- anvilbook-0.1.1/src/anvilbook/discover.py +52 -0
- anvilbook-0.1.1/src/anvilbook/disenchant.py +104 -0
- anvilbook-0.1.1/src/anvilbook/importer.py +121 -0
- anvilbook-0.1.1/src/anvilbook/installer.py +32 -0
- anvilbook-0.1.1/src/anvilbook/items.py +15 -0
- anvilbook-0.1.1/src/anvilbook/luatable.py +89 -0
- anvilbook-0.1.1/src/anvilbook/plan.py +121 -0
- anvilbook-0.1.1/src/anvilbook/recipes.py +73 -0
- anvilbook-0.1.1/src/anvilbook/static/index.html +679 -0
- anvilbook-0.1.1/src/anvilbook/store.py +147 -0
- anvilbook-0.1.1/src/anvilbook/watcher.py +62 -0
- anvilbook-0.1.1/tests/addon_harness.lua +105 -0
- anvilbook-0.1.1/tests/addon_harness_modern.lua +186 -0
- anvilbook-0.1.1/tests/lua_fixture.py +47 -0
- anvilbook-0.1.1/tests/test_addon.py +18 -0
- anvilbook-0.1.1/tests/test_app.py +358 -0
- anvilbook-0.1.1/tests/test_config.py +28 -0
- anvilbook-0.1.1/tests/test_craft.py +131 -0
- anvilbook-0.1.1/tests/test_discover.py +34 -0
- anvilbook-0.1.1/tests/test_disenchant.py +42 -0
- anvilbook-0.1.1/tests/test_disenchant_records.py +42 -0
- anvilbook-0.1.1/tests/test_importer.py +79 -0
- anvilbook-0.1.1/tests/test_installer.py +49 -0
- anvilbook-0.1.1/tests/test_items.py +17 -0
- anvilbook-0.1.1/tests/test_luatable.py +59 -0
- anvilbook-0.1.1/tests/test_plan.py +71 -0
- anvilbook-0.1.1/tests/test_recipes.py +145 -0
- anvilbook-0.1.1/tests/test_store.py +66 -0
anvilbook-0.1.1/LICENSE
ADDED
|
@@ -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.
|
anvilbook-0.1.1/PKG-INFO
ADDED
|
@@ -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,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
|