23 Commits

Author SHA1 Message Date
b8b79a0ea5 currency count no longer counts deleted items 2025-10-01 22:21:47 +02:00
20fbdfdea9 Update data files with correct set and unique names 2025-10-01 20:13:14 +00:00
a8938264b0 Make set/unique extract script use item-names.json 2025-10-01 20:13:14 +00:00
8cca6e3464 Implement names for stat 107 and 188 2025-09-28 12:08:51 +02:00
9e7d69f667 Add skills.json parser & data
Build a lookup table to map from Id to string.
2025-09-27 13:44:52 +02:00
bd96f1e0ac Implement showing item details on hover in the storage displays 2025-09-26 22:54:06 +02:00
85003665c9 Update README with instructions for running the webserver 2025-09-25 21:06:17 +02:00
78c22bc84f Add select all feature to stash tabs 2025-09-25 20:58:20 +02:00
f25180c3cf Update to new bitarray API in the item code huffman decoding 2025-09-25 20:54:13 +02:00
21cf1be326 Fix crash when zombie processes exist in the psutil result 2025-09-25 20:53:11 +02:00
9b288a39dc Add the missing currency template for the currency ui (oops) 2023-10-31 12:08:42 +01:00
8b94171d22 Add stat fire-max for Cathan's Rule 2023-10-31 12:00:41 +01:00
9057f81d5f Add currency display page 2023-10-31 11:44:14 +01:00
2373702a1f fix storage_count to not count items that are removed from storage 2023-10-30 11:05:45 +01:00
4788d0d641 Add simple flask webapp to move items between stash and database 2023-10-29 21:45:55 +01:00
21df28d7bb Split sockets into sockets and socketed_items 2023-10-29 20:54:05 +01:00
423e4368d7 Fix bugs with affixes lookup 2023-10-29 20:48:33 +01:00
5026f58eb8 Fix an issue in rebuilding stash data for socketed items 2023-10-28 06:17:58 +02:00
b73fd9c20e make d2warehouse.db ready for the flask webui
- Add a function to automatically initialize the db with the schema if
   it isn't yet initialized
 - Remove the DROP TABLE queries from the schema in case the above^ goes
   wrong somehow
 - Make Item.write_to_db and Item.load_from_db take a db argument so
   that multiple threads can be supported
 - Add a few properties to d2warehouse.Item to help display items
2023-10-28 06:17:54 +02:00
93b1ad25a6 Add requirements to db write 2023-10-27 18:09:57 +02:00
88ecf7f700 Add prefix/suffix extraction 2023-10-27 17:36:45 +02:00
7251805332 Add req lvl, str & dex to items.json 2023-10-27 16:54:10 +02:00
40276cb0b4 Add basic sqlite3 saving & loading support 2023-10-27 13:48:59 +00:00
32 changed files with 12309 additions and 819 deletions

View File

@@ -1,8 +1,23 @@
Quick & dirty commit of current progress to share with others.
# Installation
Don't.
# Development installation
Create a virtual environment and install the development set:
```
python -m venv venv
source venv/bin/activate
pip install --editable .[dev]
```
Set your save path and run the webserver to interact with the storage system:
```
export D2SAVE_PATH="/path/to/saves"
flask --app d2warehouse.app run
```
Some debug tooling:
```
d2dump /path/to/stash.d2i
```

43
contrib/affixes.py Normal file
View File

@@ -0,0 +1,43 @@
import csv
import json
import os
import sys
path = sys.argv[1] if len(sys.argv) >= 2 else "."
category = "Base"
affixes = {"prefixes": {}, "suffixes": {}}
with open(os.path.join(path, "magicprefix.txt")) as f:
dr = csv.DictReader(f, delimiter="\t")
index = 0
for row in dr:
if row["Name"] == "Expansion":
continue
index += 1
if len(row["Name"]) == 0:
continue
affixes["prefixes"][index] = {
"name": row["Name"],
"req_lvl": 0 if len(row["levelreq"]) == 0 else int(row["levelreq"]),
"req_class": None if len(row["class"]) == 0 else row["class"],
}
with open(os.path.join(path, "magicsuffix.txt")) as f:
dr = csv.DictReader(f, delimiter="\t")
index = 0
for row in dr:
if row["Name"] == "Expansion":
continue
index += 1
if len(row["Name"]) == 0:
continue
affixes["suffixes"][index] = {
"name": row["Name"],
"req_lvl": 0 if len(row["levelreq"]) == 0 else int(row["levelreq"]),
"req_class": None if len(row["class"]) == 0 else row["class"],
}
with open("affixes.json", "w", newline="\n") as f:
json.dump(affixes, f, indent=4)
f.write("\n")

View File

@@ -1,5 +1,9 @@
import json
import csv
import os
import sys
path = sys.argv[1] if len(sys.argv) >= 2 else "."
items = {}
@@ -9,7 +13,7 @@ item_patches = {
}
# build code -> names map
with open("item-names.json", encoding="utf-8-sig") as f:
with open(os.path.join(path, "item-names.json"), encoding="utf-8-sig") as f:
names = json.load(f)
names = []
@@ -21,7 +25,7 @@ for entry in names:
names[code] = {"name": name}
# Extract items
with open("armor.txt", newline="") as f:
with open(os.path.join(path, "armor.txt"), newline="") as f:
reader = csv.DictReader(f, delimiter="\t")
for row in reader:
if row["name"] == "Expansion":
@@ -35,9 +39,12 @@ with open("armor.txt", newline="") as f:
"stackable": row["stackable"] == "1",
"width": int(row["invwidth"]),
"height": int(row["invheight"]),
"req_str": int(row["reqstr"]),
"req_dex": int(row["reqdex"]),
"req_lvl": int(row["levelreq"]),
}
with open("weapons.txt", newline="") as f:
with open(os.path.join(path, "weapons.txt"), newline="") as f:
reader = csv.DictReader(f, delimiter="\t")
for row in reader:
if row["name"] == "Expansion":
@@ -53,9 +60,12 @@ with open("weapons.txt", newline="") as f:
"stackable": row["stackable"] == "1",
"width": int(row["invwidth"]),
"height": int(row["invheight"]),
"req_str": 0 if len(row["reqstr"]) == 0 else int(row["reqstr"]),
"req_dex": 0 if len(row["reqdex"]) == 0 else int(row["reqdex"]),
"req_lvl": int(row["levelreq"]),
}
with open("misc.txt", newline="") as f:
with open(os.path.join(path, "misc.txt"), newline="") as f:
reader = csv.DictReader(f, delimiter="\t")
for row in reader:
if row["name"] == "Expansion":
@@ -69,10 +79,14 @@ with open("misc.txt", newline="") as f:
"stackable": row["stackable"] == "1",
"width": int(row["invwidth"]),
"height": int(row["invheight"]),
"req_str": 0,
"req_dex": 0,
"req_lvl": int(row["levelreq"]),
}
for code, patch in item_patches.items():
items[code].update(patch)
with open("items.json", "w") as f:
with open("items.json", "w", newline="\n") as f:
json.dump(items, f, indent=4)
f.write("\n")

28
contrib/skills.py Normal file
View File

@@ -0,0 +1,28 @@
import json
import csv
import os
import sys
path = sys.argv[1] if len(sys.argv) >= 2 else "."
items = {}
item_patches = {
"tbk": {"class": "tome"},
"ibk": {"class": "tome"},
}
with open(os.path.join(path, "skills.json"), encoding="utf-8-sig") as f:
rows = json.load(f)
lookup_table = {}
for entry in rows:
key = entry["Key"]
text = entry["enUS"]
if len(text.strip()) == 0:
continue
lookup_table[key] = text
with open("skills.json", "w", newline="\n") as f:
json.dump(lookup_table, f, indent=4)
f.write("\n")

View File

@@ -13,7 +13,9 @@ path = sys.argv[1] if len(sys.argv) >= 2 else "."
# into one. Same applies for all stats.
special_stats = {
"firemindam": {"template": "dmg-fire"},
"firemaxdam": None,
"firemaxdam": {
"template": "fire-max"
}, # Cathan's rule, no other max ele dmg source exists
"lightmindam": {"template": "dmg-ltng"},
"lightmaxdam": None,
"magicmindam": {"template": "dmg-mag"},

View File

@@ -1,21 +1,36 @@
import csv
import json
import os
import sys
import argparse
from pathlib import Path
path = sys.argv[1] if len(sys.argv) >= 2 else "."
parser = argparse.ArgumentParser(
description="Process unique and set items from game data"
)
parser.add_argument(
"DATA_DIR", help="Path to d2 data dir containing local/ and global/"
)
parser.add_argument("OUTPUT_DIR", help="Path to destination directory")
args = parser.parse_args()
excelpath = Path(args.DATA_DIR) / "global/excel"
outputpath = Path(args.OUTPUT_DIR)
namespath = Path(args.DATA_DIR) / "local/lng/strings/item-names.json"
with namespath.open(encoding="utf-8-sig") as f:
names = json.load(f)
names = {name["Key"]: name["enUS"] for name in names}
category = "Base"
setitems = {}
with open(os.path.join(path, "setitems.txt")) as f:
with (excelpath / "setitems.txt").open() as f:
dr = csv.DictReader(f, delimiter="\t")
for row in dr:
if row["index"] == "Expansion":
category = row["index"]
continue
setitems[row["*ID"]] = {
"name": row["index"],
"set": row["set"],
"name": names[row["index"]],
"set": names[row["set"]],
"itembase": row["item"],
"req_lvl": int(row["lvl req"]),
"ilvl": int(row["lvl"]),
@@ -25,7 +40,7 @@ with open(os.path.join(path, "setitems.txt")) as f:
category = "Base"
uniqueitems = {}
with open(os.path.join(path, "uniqueitems.txt")) as f:
with (excelpath / "uniqueitems.txt").open() as f:
dr = csv.DictReader(f, delimiter="\t")
for row in dr:
if row["index"] in [
@@ -42,7 +57,7 @@ with open(os.path.join(path, "uniqueitems.txt")) as f:
if len(row["lvl req"]) == 0:
continue # deleted uniques
uniqueitems[row["*ID"]] = {
"name": row["index"],
"name": names[row["index"]],
"itembase": row["code"],
"req_lvl": int(row["lvl req"]),
"ilvl": int(row["lvl"]),
@@ -50,10 +65,10 @@ with open(os.path.join(path, "uniqueitems.txt")) as f:
"category": category,
}
with open("uniques.json", "w", newline="\n") as f:
with (outputpath / "uniques.json").open("w", newline="\n") as f:
json.dump(uniqueitems, f, indent=4)
f.write("\n")
with open("sets.json", "w", newline="\n") as f:
with (outputpath / "sets.json").open("w", newline="\n") as f:
json.dump(setitems, f, indent=4)
f.write("\n")

View File

@@ -0,0 +1,5 @@
from d2warehouse.app.main import app
__all__ = [
"app",
]

22
d2warehouse/app/db.py Normal file
View File

@@ -0,0 +1,22 @@
import sqlite3
from flask import g
import d2warehouse.db as base_db
def get_db():
if "db" not in g:
print("\n==========\nDB PATH", base_db._path, "\n============\n")
g.db = sqlite3.connect(
base_db._path,
detect_types=sqlite3.PARSE_DECLTYPES,
)
g.db.row_factory = sqlite3.Row
return g.db
def close_db(e=None):
db = g.pop("db", None)
if db is not None:
db.close()

345
d2warehouse/app/main.py Normal file
View File

@@ -0,0 +1,345 @@
import hashlib
from flask import Flask, redirect, abort, render_template, request
from pathlib import Path
import shutil
from datetime import datetime
import psutil
from d2warehouse.item import Item, Quality, lookup_basetype
from d2warehouse.parser import parse_stash
import d2warehouse.db as base_db
from d2warehouse.app.db import get_db, close_db
import os
import re
from d2warehouse.stash import StashFullError
STASH_FILES = {
"softcore": "SharedStashSoftCoreV2.d2i",
"hardcore": "SharedStashHardCoreV2.d2i",
}
DB_FILES = {
"softcore": "d2warehouse.softcore.sqlite3",
"hardcore": "d2warehouse.hardcore.sqlite3",
}
CURRENCY_RUNES = [f"r{i + 1:02d}" for i in range(33)]
CURRENCY_GEMS = [
"gcv",
"gfv",
"gsv",
"gzv",
"gpv",
"gcb",
"gfb",
"gsb",
"glb",
"gpb",
"gcg",
"gfg",
"gsg",
"glg",
"gpg",
"gcr",
"gfr",
"gsr",
"glr",
"gpr",
"gcw",
"gfw",
"gsw",
"glw",
"gpw",
"gcy",
"gfy",
"gsy",
"gly",
"gpy",
"skc",
"skf",
"sku",
"skl",
"skz",
]
CURRENCY_KEYS = [
"pk1",
"pk2",
"pk3",
"bey",
"mbr",
"dhn",
]
CURRENCY_ESSENCES = [
"tes",
"ceh",
"bet",
"fed",
"toa",
]
def d2_running() -> bool:
for proc in psutil.process_iter():
try:
if proc.cmdline()[0].endswith("D2R.exe"):
return True
except (IndexError, psutil.AccessDenied, psutil.ZombieProcess):
pass
return False
def storage_count(item: Item, stash: str) -> int | str:
"""How many of this item type exist in storage"""
db = get_stash_db(stash)
if item.is_simple:
return db.execute(
"SELECT COUNT(id) FROM item WHERE code = ? AND deleted IS NULL",
(item.code,),
).fetchone()[0]
elif item.quality == Quality.UNIQUE:
return db.execute(
"SELECT COUNT(id) FROM item INNER JOIN item_extra ON id = item_id WHERE code = ? AND unique_id = ? AND deleted IS NULL",
(item.code, item.unique_id),
).fetchone()[0]
elif item.quality == Quality.SET:
return db.execute(
"SELECT COUNT(id) FROM item INNER JOIN item_extra ON id = item_id WHERE code = ? AND set_id = ? AND deleted IS NULL",
(item.code, item.set_id),
).fetchone()[0]
elif item.is_runeword:
return db.execute(
"SELECT COUNT(id) FROM item INNER JOIN item_extra ON id = item_id WHERE code = ? AND runeword_id = ? AND deleted IS NULL",
(item.code, item.runeword_id),
).fetchone()[0]
else:
return "N/A"
def save_path() -> Path:
if "D2SAVE_PATH" in os.environ:
path = Path(os.environ["D2SAVE_PATH"])
else:
path = Path.home() / "Saved Games/Diablo II Resurrected"
if not path.exists():
raise RuntimeError(f"Save path `{path}` does not exist")
return path
def get_stash_db(stash):
base_db.set_db_path(str(save_path() / DB_FILES[stash]))
return get_db()
base_db.set_db_path(str(save_path() / DB_FILES["softcore"]))
base_db.init_db()
base_db.close_db()
base_db.set_db_path(str(save_path() / DB_FILES["hardcore"]))
base_db.init_db()
base_db.close_db()
app = Flask(__name__)
app.teardown_appcontext(close_db)
@app.route("/")
def home():
return redirect("/stash/softcore", code=302)
@app.route("/stash/<stash_name>")
def list_stash(stash_name: str):
if stash_name not in STASH_FILES:
abort(404)
path = save_path() / STASH_FILES[stash_name]
stash_data = path.read_bytes()
stash_hash = hashlib.sha256(stash_data).hexdigest()
stash = parse_stash(stash_data)
return render_template(
"list_stash.html",
stash_name=stash_name,
stash=stash,
stash_hash=stash_hash,
storage_count=lambda x: storage_count(x, stash_name),
)
@app.route("/stash/<stash_name>/store", methods=["POST"])
def stash_store_items(stash_name: str):
if stash_name not in STASH_FILES or stash_name not in DB_FILES:
abort(404)
stash_path = save_path() / STASH_FILES[stash_name]
tmp_path = save_path() / f"{STASH_FILES[stash_name]}.temp"
if tmp_path.exists():
# TODO: Handle this condition
return "temp file exists (BAD)"
return 500
if d2_running():
return "d2 is running", 500
stash_data = stash_path.read_bytes()
stash_hash = hashlib.sha256(stash_data).hexdigest()
if request.form.get("stash_hash") != stash_hash:
return "wrong stash hash", 400
stash = parse_stash(stash_data)
items = []
locs = [y for x in request.form.keys() if (y := re.match(r"item_(\d+)_(\d+)", x))]
for item_location in locs:
tab_idx, item_idx = int(item_location.group(1)), int(item_location.group(2))
if tab_idx > len(stash.tabs) or item_idx > len(stash.tabs[tab_idx].items):
# TODO: Handle this condition
return "invalid position (2)"
item = stash.tabs[tab_idx].items[item_idx]
items.append((tab_idx, item))
backup_stash(stash_name)
for tab_idx, item in items:
stash.tabs[tab_idx].remove(item)
tmp_path.write_bytes(stash.raw())
db = get_stash_db(stash_name)
for _, item in items:
item.write_to_db(db=db)
tmp_path.replace(stash_path)
return redirect(f"/stash/{stash_name}", code=303)
@app.route("/storage/<stash_name>")
def list_storage(stash_name: str):
if stash_name not in DB_FILES:
abort(404)
db = get_stash_db(stash_name)
items = {}
rows = db.execute("SELECT id FROM item WHERE deleted IS NULL").fetchall()
for row in rows:
items[row["id"]] = Item.load_from_db(row["id"], db=db)
return render_template(
"list_storage.html",
stash_name=stash_name,
storage_items=items,
storage_count=lambda x: storage_count(x, stash_name),
)
@app.route("/storage/<stash_name>/<category>")
def list_storage_category(stash_name: str, category: str):
if stash_name not in DB_FILES:
abort(404)
db = get_stash_db(stash_name)
if category == "uniques":
q = db.execute(
"SELECT id FROM item INNER JOIN item_extra ON id = item_id WHERE deleted IS NULL AND quality = ?",
(int(Quality.UNIQUE),),
)
elif category == "sets":
q = db.execute(
"SELECT id FROM item INNER JOIN item_extra ON id = item_id WHERE deleted IS NULL AND quality = ?",
(int(Quality.SET),),
)
elif category == "misc":
q = db.execute("SELECT id FROM item WHERE deleted IS NULL AND is_simple = TRUE")
else:
return "Unexpected category", 400
rows = q.fetchall()
items = {}
for row in rows:
items[row["id"]] = Item.load_from_db(row["id"], db=db)
return render_template(
"list_storage.html",
stash_name=stash_name,
storage_items=items,
storage_count=lambda x: storage_count(x, stash_name),
)
def backup_stash(stash_name: str) -> None:
stash_path = save_path() / STASH_FILES[stash_name]
backup_path = save_path() / "backups"
if not backup_path.exists():
backup_path.mkdir(parents=True)
ts = datetime.now().strftime("%Y-%m-%d_%H.%M.%S.%f")[:-3]
backup_path /= f"{ts}_{STASH_FILES[stash_name]}"
shutil.copy(stash_path, backup_path)
@app.route("/storage/<stash_name>/take", methods=["POST"])
def storage_take_items(stash_name: str):
if stash_name not in STASH_FILES or stash_name not in DB_FILES:
abort(404)
stash_path = save_path() / STASH_FILES[stash_name]
tmp_path = save_path() / f"{STASH_FILES[stash_name]}.temp"
if tmp_path.exists():
# TODO: Handle this condition
return "temp file exists (BAD)"
return 500
if d2_running():
return "d2 is running", 500
stash_data = stash_path.read_bytes()
stash = parse_stash(stash_data)
backup_stash(stash_name)
# Write items to temporary stash file
db = get_stash_db(stash_name)
ids = [
int(y.group(1))
for x in request.form.keys()
if (y := re.match(r"item_(\d+)", x))
]
for id in ids:
item = Item.load_from_db(id, db=db)
try:
stash.add(item)
except StashFullError:
return "the shared stash does not fit those items", 500
tmp_path.write_bytes(stash.raw())
# Remove items from db
for id in ids:
db.execute("UPDATE item SET deleted = CURRENT_TIMESTAMP WHERE id = ?", (id,))
db.commit()
# Finalize by replacing real stash file
tmp_path.replace(stash_path)
return redirect(f"/storage/{stash_name}", code=303)
def storage_currency_counts(item_codes: list[str], stash_name: str) -> dict:
db = get_stash_db(stash_name)
currencies = {}
for code in item_codes:
currencies[code] = {
"count": db.execute(
"SELECT COUNT(id) FROM item WHERE code = ? AND deleted IS NULL", (code,)
).fetchone()[0],
"name": lookup_basetype(code)["name"],
}
return currencies
@app.route("/storage/<stash_name>/currency")
def storage_currency(stash_name: str):
if stash_name not in DB_FILES:
abort(404)
runes = storage_currency_counts(CURRENCY_RUNES, stash_name)
gems = storage_currency_counts(CURRENCY_GEMS, stash_name)
keys = storage_currency_counts(CURRENCY_KEYS, stash_name)
essences = storage_currency_counts(CURRENCY_ESSENCES, stash_name)
return render_template(
"currency.html", runes=runes, gems=gems, keys=keys, essences=essences
)

View File

@@ -0,0 +1,13 @@
function toggleSelectAll(tabIndex) {
const tab = document.querySelector(`[data-tab="${tabIndex}"]`);
const checkboxes = tab.querySelectorAll('input[type="checkbox"]');
if (checkboxes.length === 0)
return;
const allSelected = Array.from(checkboxes).every(cb => cb.checked);
checkboxes.forEach(cb => {
cb.checked = !allSelected;
});
}

View File

@@ -0,0 +1,106 @@
body {
background-color: #000;
font-size: large;
font-family: sans-serif;
color: rgb(240, 240, 240);
}
.stash-tab {
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-gap: 10px;
margin: 0 auto;
}
.currencies {
display: flex;
gap: 50px
}
.currencies th {
text-align: left;
}
.currencies td {
text-align: right;
}
@media (max-width: 1600px) {
.stash-tab {
grid-template-columns: repeat(4, 1fr);
}
}
@media (max-width: 1200px) {
.stash-tab {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 800px) {
.stash-tab {
grid-template-columns: repeat(2, 1fr);
}
}
.stash-tab input[type="checkbox"] {
display: none;
}
.storage-item-entry {
position: relative;
width: fit-content;
}
.item-hover {
display: none;
position: absolute;
left: 0;
top: 30px;
z-index: 1000;
background: #222;
border: 1px solid #555;
padding: 8px;
min-width: 300px;
}
.storage-item-entry:hover .item-hover {
display: block;
}
.item .name {
font-weight: bold;
}
.item {
display: block;
background-color: #444;
border: solid #555;
}
input[type="checkbox"]:checked + label {
background-color: #343;
border: solid #464;
}
.color-rare {
color: rgb(255, 255, 100);
}
.color-unique {
color: rgb(199, 179, 119);
}
.color-set {
color: rgb(0, 252, 0);
}
.color-runeword {
color: rgb(199, 179, 119);
}
.raw-item {
max-width: 120px;
background-color: #444;
color: rgb(240, 240, 240);
}

View File

@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Shared Stash</title>
<link rel="stylesheet" href="/static/style.css" />
<head>
<body>
<div class="currencies">
<div>
<table>
{% for code,currency in runes.items() %}
<tr>
<th>{{currency.name}}</th>
<td>{{currency.count}}</td>
</tr>
{% endfor %}
</table>
</div>
<div>
<table>
{% for code,currency in gems.items() %}
<tr>
<th>{{currency.name}}</th>
<td>{{currency.count}}</td>
</tr>
{% endfor %}
</table>
</div>
<div>
<table>
{% for code,currency in keys.items() %}
<tr>
<th>{{currency.name}}</th>
<td>{{currency.count}}</td>
</tr>
{% endfor %}
</table>
</div>
<div>
<table>
{% for code,currency in essences.items() %}
<tr>
<th>{{currency.name}}</th>
<td>{{currency.count}}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,13 @@
<ul>
<li class="name color-{{item.color}}">{{item.name}}</li>
{% if item.quality and item.quality >= 5 %}
<li class="name color-{{item.color}}">{{item.basename}}</li>
{% endif %}
<li>(in storage: {{storage_count(item)}})</li>
{% if item.stats %}
{% for stat in item.stats %}
<li>{{stat}}</li>
{% endfor %}
{% endif %}
<li><input class="raw-item" type="text" name="raw item" value="{{item.raw().hex()}}" onfocus="this.select()" readonly></li>
</ul>

View File

@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Shared Stash</title>
<link rel="stylesheet" href="/static/style.css" />
<script src="/static/helpers.js"></script>
<head>
<body>
<form action="/stash/{{stash_name}}/store" method="POST">
{% for tab in stash.tabs %}
{% set tabloop = loop %}
<h2>Tab {{tabloop.index}} <button type="button" onclick="toggleSelectAll({{tabloop.index}})">Select All</button> </h2>
<div class="stash-tab" data-tab="{{tabloop.index}}">
{% for item in tab.items %}
{% set itemloop = loop %}
<input type="checkbox" id="item_{{tabloop.index0}}_{{itemloop.index0}}" name="item_{{tabloop.index0}}_{{itemloop.index0}}" value="remove" />
<label class="item" for="item_{{tabloop.index0}}_{{itemloop.index0}}">
{% include "item.html" %}
</label>
{% endfor %}
</div>
{% endfor %}
<input type="submit" value="Store items">
<input type="hidden" name="stash_hash" value="{{stash_hash}}" />
</form>
</body>
</html>

View File

@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Storage</title>
<link rel="stylesheet" href="/static/style.css" />
<head>
<body>
<form action="/storage/{{stash_name}}/take" method="POST">
<div>
<!-- TODO: Include item.html -->
{% for db_id, item in storage_items.items() %}
<div class="storage-item-entry">
<input type="checkbox" name="item_{{db_id}}" id="item_{{db_id}}" value="take" />
<label for="item_{{db_id}}">{{item.name}} ({{db_id}})</label>
<div class="item-hover">
{% include "item.html" %}
</div>
</div>
{% endfor %}
</div>
<input type="submit" value="Take items">
</form>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -397,7 +397,7 @@
},
"44": {
"name": "Berserker's Headgear",
"set": "Berserker's Garb",
"set": "Berserker's Arsenal",
"itembase": "hlm",
"req_lvl": 3,
"ilvl": 5,
@@ -406,7 +406,7 @@
},
"45": {
"name": "Berserker's Hauberk",
"set": "Berserker's Garb",
"set": "Berserker's Arsenal",
"itembase": "spl",
"req_lvl": 3,
"ilvl": 5,
@@ -415,7 +415,7 @@
},
"46": {
"name": "Berserker's Hatchet",
"set": "Berserker's Garb",
"set": "Berserker's Arsenal",
"itembase": "2ax",
"req_lvl": 3,
"ilvl": 5,
@@ -451,7 +451,7 @@
},
"50": {
"name": "Angelic Sickle",
"set": "Angelical Raiment",
"set": "Angelic Raiment",
"itembase": "sbr",
"req_lvl": 12,
"ilvl": 17,
@@ -460,7 +460,7 @@
},
"51": {
"name": "Angelic Mantle",
"set": "Angelical Raiment",
"set": "Angelic Raiment",
"itembase": "rng",
"req_lvl": 12,
"ilvl": 17,
@@ -469,7 +469,7 @@
},
"52": {
"name": "Angelic Halo",
"set": "Angelical Raiment",
"set": "Angelic Raiment",
"itembase": "rin",
"req_lvl": 12,
"ilvl": 17,
@@ -478,7 +478,7 @@
},
"53": {
"name": "Angelic Wings",
"set": "Angelical Raiment",
"set": "Angelic Raiment",
"itembase": "amu",
"req_lvl": 12,
"ilvl": 17,
@@ -612,7 +612,7 @@
"category": "Expansion"
},
"68": {
"name": "Aldur's Gauntlet",
"name": "Aldur's Rhythm",
"set": "Aldur's Watchtower",
"itembase": "9mt",
"req_lvl": 42,
@@ -684,7 +684,7 @@
"category": "Expansion"
},
"76": {
"name": "Tal Rasha's Fire-Spun Cloth",
"name": "Tal Rasha's Fine-Spun Cloth",
"set": "Tal Rasha's Wrappings",
"itembase": "zmb",
"req_lvl": 53,
@@ -711,7 +711,7 @@
"category": "Expansion"
},
"79": {
"name": "Tal Rasha's Howling Wind",
"name": "Tal Rasha's Guardianship",
"set": "Tal Rasha's Wrappings",
"itembase": "uth",
"req_lvl": 71,
@@ -747,7 +747,7 @@
"category": "Expansion"
},
"83": {
"name": "Griswolds's Redemption",
"name": "Griswold's Redemption",
"set": "Griswold's Legacy",
"itembase": "7ws",
"req_lvl": 53,
@@ -882,7 +882,7 @@
"category": "Expansion"
},
"98": {
"name": "Spiritual Custodian",
"name": "Dark Adherent",
"set": "The Disciple",
"itembase": "uui",
"req_lvl": 43,
@@ -909,7 +909,7 @@
"category": "Expansion"
},
"101": {
"name": "Heaven's Taebaek",
"name": "Taebaek's Glory",
"set": "Heaven's Brethren",
"itembase": "uts",
"req_lvl": 81,
@@ -918,7 +918,7 @@
"category": "Expansion"
},
"102": {
"name": "Haemosu's Adament",
"name": "Haemosu's Adamant",
"set": "Heaven's Brethren",
"itembase": "xrs",
"req_lvl": 44,
@@ -963,7 +963,7 @@
"category": "Expansion"
},
"107": {
"name": "Wihtstan's Guard",
"name": "Whitstan's Guard",
"set": "Orphan's Call",
"itembase": "xml",
"req_lvl": 29,
@@ -990,7 +990,7 @@
"category": "Expansion"
},
"110": {
"name": "Hwanin's Seal",
"name": "Hwanin's Blessing",
"set": "Hwanin's Majesty",
"itembase": "mbl",
"req_lvl": 35,
@@ -1071,7 +1071,7 @@
"category": "Expansion"
},
"119": {
"name": "Cow King's Hoofs",
"name": "Cow King's Hooves",
"set": "Cow King's Leathers",
"itembase": "vbt",
"req_lvl": 13,
@@ -1081,7 +1081,7 @@
},
"120": {
"name": "Naj's Puzzler",
"set": "Naj's Ancient Set",
"set": "Naj's Ancient Vestige",
"itembase": "6cs",
"req_lvl": 78,
"ilvl": 43,
@@ -1090,7 +1090,7 @@
},
"121": {
"name": "Naj's Light Plate",
"set": "Naj's Ancient Set",
"set": "Naj's Ancient Vestige",
"itembase": "ult",
"req_lvl": 71,
"ilvl": 43,
@@ -1099,7 +1099,7 @@
},
"122": {
"name": "Naj's Circlet",
"set": "Naj's Ancient Set",
"set": "Naj's Ancient Vestige",
"itembase": "ci0",
"req_lvl": 28,
"ilvl": 43,
@@ -1107,8 +1107,8 @@
"category": "Expansion"
},
"123": {
"name": "McAuley's Paragon",
"set": "McAuley's Folly",
"name": "Sander's Paragon",
"set": "Sander's Folly",
"itembase": "cap",
"req_lvl": 25,
"ilvl": 20,
@@ -1116,8 +1116,8 @@
"category": "Expansion"
},
"124": {
"name": "McAuley's Riprap",
"set": "McAuley's Folly",
"name": "Sander's Riprap",
"set": "Sander's Folly",
"itembase": "vbt",
"req_lvl": 20,
"ilvl": 20,
@@ -1125,8 +1125,8 @@
"category": "Expansion"
},
"125": {
"name": "McAuley's Taboo",
"set": "McAuley's Folly",
"name": "Sander's Taboo",
"set": "Sander's Folly",
"itembase": "vgl",
"req_lvl": 28,
"ilvl": 20,
@@ -1134,8 +1134,8 @@
"category": "Expansion"
},
"126": {
"name": "McAuley's Superstition",
"set": "McAuley's Folly",
"name": "Sander's Superstition",
"set": "Sander's Folly",
"itembase": "bwn",
"req_lvl": 25,
"ilvl": 20,

1251
d2warehouse/data/skills.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -273,6 +273,14 @@
"save_add": 0,
"save_param_bits": null
},
"49": {
"text": "+# to Maximum Fire Damage",
"save_bits": [
9
],
"save_add": 0,
"save_param_bits": null
},
"50": {
"text": "Adds #-# Lightning Damage",
"save_bits": [

View File

@@ -24,7 +24,7 @@
"category": "Base"
},
"3": {
"name": "Mindrend",
"name": "Skull Splitter",
"itembase": "mpi",
"req_lvl": 21,
"ilvl": 28,
@@ -40,7 +40,7 @@
"category": "Base"
},
"5": {
"name": "Fechmars Axe",
"name": "Axe of Fechmar",
"itembase": "lax",
"req_lvl": 8,
"ilvl": 11,
@@ -56,7 +56,7 @@
"category": "Base"
},
"7": {
"name": "The Chieftan",
"name": "The Chieftain",
"itembase": "btx",
"req_lvl": 19,
"ilvl": 26,
@@ -72,7 +72,7 @@
"category": "Base"
},
"9": {
"name": "The Humongous",
"name": "Humongous",
"itembase": "gix",
"req_lvl": 29,
"ilvl": 39,
@@ -80,7 +80,7 @@
"category": "Base"
},
"10": {
"name": "Iros Torch",
"name": "Torch of Iro",
"itembase": "wnd",
"req_lvl": 5,
"ilvl": 7,
@@ -88,7 +88,7 @@
"category": "Base"
},
"11": {
"name": "Maelstromwrath",
"name": "Maelstrom",
"itembase": "ywn",
"req_lvl": 14,
"ilvl": 19,
@@ -104,7 +104,7 @@
"category": "Base"
},
"13": {
"name": "Umes Lament",
"name": "Ume's Lament",
"itembase": "gwn",
"req_lvl": 28,
"ilvl": 38,
@@ -168,7 +168,7 @@
"category": "Base"
},
"21": {
"name": "The Generals Tan Do Li Ga",
"name": "The General's Tan Do Li Ga",
"itembase": "fla",
"req_lvl": 21,
"ilvl": 28,
@@ -184,7 +184,7 @@
"category": "Base"
},
"23": {
"name": "Bonesob",
"name": "Bonesnap",
"itembase": "mau",
"req_lvl": 24,
"ilvl": 32,
@@ -200,7 +200,7 @@
"category": "Base"
},
"25": {
"name": "Rixots Keen",
"name": "Rixot's Keen",
"itembase": "ssd",
"req_lvl": 2,
"ilvl": 3,
@@ -216,7 +216,7 @@
"category": "Base"
},
"27": {
"name": "Krintizs Skewer",
"name": "Skewer of Krintiz",
"itembase": "sbr",
"req_lvl": 10,
"ilvl": 14,
@@ -240,7 +240,7 @@
"category": "Base"
},
"30": {
"name": "Griswolds Edge",
"name": "Griswold's Edge",
"itembase": "bsd",
"req_lvl": 17,
"ilvl": 23,
@@ -256,7 +256,7 @@
"category": "Base"
},
"32": {
"name": "Culwens Point",
"name": "Culwen's Point",
"itembase": "wsd",
"req_lvl": 29,
"ilvl": 39,
@@ -280,7 +280,7 @@
"category": "Base"
},
"35": {
"name": "Kinemils Awl",
"name": "Kinemil's Awl",
"itembase": "gis",
"req_lvl": 23,
"ilvl": 31,
@@ -336,7 +336,7 @@
"category": "Base"
},
"42": {
"name": "Irices Shard",
"name": "Spectral Shard",
"itembase": "bld",
"req_lvl": 25,
"ilvl": 34,
@@ -384,7 +384,7 @@
"category": "Base"
},
"48": {
"name": "Dimoaks Hew",
"name": "Dimoak's Hew",
"itembase": "bar",
"req_lvl": 8,
"ilvl": 11,
@@ -448,7 +448,7 @@
"category": "Base"
},
"56": {
"name": "Lazarus Spire",
"name": "Spire of Lazarus",
"itembase": "cst",
"req_lvl": 18,
"ilvl": 24,
@@ -488,7 +488,7 @@
"category": "Base"
},
"61": {
"name": "Rimeraven",
"name": "Raven Claw",
"itembase": "lbw",
"req_lvl": 15,
"ilvl": 20,
@@ -496,7 +496,7 @@
"category": "Base"
},
"62": {
"name": "Piercerib",
"name": "Rogue's Bow",
"itembase": "cbw",
"req_lvl": 20,
"ilvl": 27,
@@ -504,7 +504,7 @@
"category": "Base"
},
"63": {
"name": "Pullspite",
"name": "Stormstrike",
"itembase": "sbb",
"req_lvl": 25,
"ilvl": 34,
@@ -560,7 +560,7 @@
"category": "Base"
},
"70": {
"name": "Doomspittle",
"name": "Doomslinger",
"itembase": "rxb",
"req_lvl": 28,
"ilvl": 38,
@@ -568,7 +568,7 @@
"category": "Base"
},
"71": {
"name": "War Bonnet",
"name": "Biggin's Bonnet",
"itembase": "cap",
"req_lvl": 3,
"ilvl": 4,
@@ -640,7 +640,7 @@
"category": "Base"
},
"80": {
"name": "Blinkbats Form",
"name": "Blinkbat's Form",
"itembase": "lea",
"req_lvl": 12,
"ilvl": 16,
@@ -688,7 +688,7 @@
"category": "Base"
},
"86": {
"name": "Venomsward",
"name": "Venom Ward",
"itembase": "brs",
"req_lvl": 20,
"ilvl": 27,
@@ -736,7 +736,7 @@
"category": "Base"
},
"92": {
"name": "Victors Silk",
"name": "Silks of the Victor",
"itembase": "aar",
"req_lvl": 28,
"ilvl": 38,
@@ -896,7 +896,7 @@
"category": "Base"
},
"112": {
"name": "Lenyms Cord",
"name": "Lenymo",
"itembase": "lbl",
"req_lvl": 7,
"ilvl": 10,
@@ -1016,7 +1016,7 @@
"category": "Base"
},
"127": {
"name": "KhalimFlail",
"name": "Khalim's Flail",
"itembase": "qf1",
"req_lvl": 0,
"ilvl": 0,
@@ -1024,7 +1024,7 @@
"category": "Base"
},
"128": {
"name": "SuperKhalimFlail",
"name": "Khalim's Will",
"itembase": "qf2",
"req_lvl": 0,
"ilvl": 0,
@@ -1056,7 +1056,7 @@
"category": "Expansion"
},
"132": {
"name": "Pompe's Wrath",
"name": "Pompeii's Wrath",
"itembase": "9mp",
"req_lvl": 45,
"ilvl": 53,
@@ -1104,7 +1104,7 @@
"category": "Expansion"
},
"138": {
"name": "The Minataur",
"name": "The Minotaur",
"itembase": "9gi",
"req_lvl": 45,
"ilvl": 53,
@@ -1288,7 +1288,7 @@
"category": "Expansion"
},
"161": {
"name": "The Atlantian",
"name": "The Atlantean",
"itembase": "9wd",
"req_lvl": 42,
"ilvl": 50,
@@ -1496,7 +1496,7 @@
"category": "Expansion"
},
"187": {
"name": "Skullcollector",
"name": "Skull Collector",
"itembase": "8ws",
"req_lvl": 41,
"ilvl": 49,
@@ -1536,7 +1536,7 @@
"category": "Expansion"
},
"192": {
"name": "Whichwild String",
"name": "Witchwild String",
"itembase": "8s8",
"req_lvl": 39,
"ilvl": 47,
@@ -1560,7 +1560,7 @@
"category": "Expansion"
},
"195": {
"name": "Godstrike Arch",
"name": "Goldstrike Arch",
"itembase": "8lw",
"req_lvl": 46,
"ilvl": 54,
@@ -1576,7 +1576,7 @@
"category": "Expansion"
},
"197": {
"name": "Pus Spiter",
"name": "Pus Spitter",
"itembase": "8mx",
"req_lvl": 36,
"ilvl": 44,
@@ -1600,7 +1600,7 @@
"category": "Expansion"
},
"201": {
"name": "Peasent Crown",
"name": "Peasant Crown",
"itembase": "xap",
"req_lvl": 28,
"ilvl": 36,
@@ -1632,7 +1632,7 @@
"category": "Armor"
},
"205": {
"name": "Valkiry Wing",
"name": "Valkyrie Wing",
"itembase": "xhm",
"req_lvl": 44,
"ilvl": 52,
@@ -1656,7 +1656,7 @@
"category": "Armor"
},
"208": {
"name": "Vampiregaze",
"name": "Vampire Gaze",
"itembase": "xh9",
"req_lvl": 41,
"ilvl": 49,
@@ -1680,7 +1680,7 @@
"category": "Armor"
},
"211": {
"name": "Skin of the Flayerd One",
"name": "Skin of the Flayed One",
"itembase": "xla",
"req_lvl": 31,
"ilvl": 39,
@@ -1688,7 +1688,7 @@
"category": "Armor"
},
"212": {
"name": "Ironpelt",
"name": "Iron Pelt",
"itembase": "xtu",
"req_lvl": 33,
"ilvl": 41,
@@ -1696,7 +1696,7 @@
"category": "Armor"
},
"213": {
"name": "Spiritforge",
"name": "Spirit Forge",
"itembase": "xng",
"req_lvl": 35,
"ilvl": 43,
@@ -1776,7 +1776,7 @@
"category": "Armor"
},
"223": {
"name": "Que-Hegan's Wisdon",
"name": "Que-Hegan's Wisdom",
"itembase": "xtp",
"req_lvl": 51,
"ilvl": 59,
@@ -1792,7 +1792,7 @@
"category": "Armor"
},
"225": {
"name": "Mosers Blessed Circle",
"name": "Moser's Blessed Circle",
"itembase": "xml",
"req_lvl": 31,
"ilvl": 39,
@@ -1816,7 +1816,7 @@
"category": "Armor"
},
"228": {
"name": "Kerke's Sanctuary",
"name": "Gerke's Sanctuary",
"itembase": "xow",
"req_lvl": 44,
"ilvl": 52,
@@ -1824,7 +1824,7 @@
"category": "Armor"
},
"229": {
"name": "Radimant's Sphere",
"name": "Radament's Sphere",
"itembase": "xts",
"req_lvl": 50,
"ilvl": 58,
@@ -1872,7 +1872,7 @@
"category": "Armor"
},
"235": {
"name": "Lavagout",
"name": "Lava Gout",
"itembase": "xtg",
"req_lvl": 42,
"ilvl": 50,
@@ -1912,7 +1912,7 @@
"category": "Armor"
},
"240": {
"name": "Wartraveler",
"name": "War Traveler",
"itembase": "xtb",
"req_lvl": 42,
"ilvl": 50,
@@ -1920,7 +1920,7 @@
"category": "Armor"
},
"241": {
"name": "Gorerider",
"name": "Gore Rider",
"itembase": "xhb",
"req_lvl": 47,
"ilvl": 55,
@@ -1944,7 +1944,7 @@
"category": "Armor"
},
"244": {
"name": "Gloomstrap",
"name": "Gloom's Trap",
"itembase": "zmb",
"req_lvl": 36,
"ilvl": 45,
@@ -1960,7 +1960,7 @@
"category": "Armor"
},
"246": {
"name": "Thudergod's Vigor",
"name": "Thundergod's Vigor",
"itembase": "zhb",
"req_lvl": 47,
"ilvl": 55,
@@ -2120,7 +2120,7 @@
"category": "Elite Uniques"
},
"268": {
"name": "Bul Katho's Wedding Band",
"name": "Bul-Kathos' Wedding Band",
"itembase": "rin",
"req_lvl": 58,
"ilvl": 66,
@@ -2256,7 +2256,7 @@
"category": "Class Specific"
},
"286": {
"name": "Cutthroat1",
"name": "Bartuc's Cut-Throat",
"itembase": "9tw",
"req_lvl": 42,
"ilvl": 50,
@@ -2288,7 +2288,7 @@
"category": "Patch 1.10+"
},
"290": {
"name": "Djinnslayer",
"name": "Djinn Slayer",
"itembase": "7sm",
"req_lvl": 65,
"ilvl": 73,
@@ -2312,7 +2312,7 @@
"category": "Patch 1.10+"
},
"293": {
"name": "Gutsiphon",
"name": "Gut Siphon",
"itembase": "6rx",
"req_lvl": 71,
"ilvl": 79,
@@ -2320,7 +2320,7 @@
"category": "Patch 1.10+"
},
"294": {
"name": "Razoredge",
"name": "Razor's Edge",
"itembase": "7ha",
"req_lvl": 67,
"ilvl": 75,
@@ -2328,7 +2328,7 @@
"category": "Patch 1.10+"
},
"296": {
"name": "Demonlimb",
"name": "Demon Limb",
"itembase": "7sp",
"req_lvl": 63,
"ilvl": 71,
@@ -2336,7 +2336,7 @@
"category": "Patch 1.10+"
},
"297": {
"name": "Steelshade",
"name": "Steel Shade",
"itembase": "ulm",
"req_lvl": 62,
"ilvl": 70,
@@ -2352,7 +2352,7 @@
"category": "Patch 1.10+"
},
"299": {
"name": "Deaths's Web",
"name": "Death's Web",
"itembase": "7gw",
"req_lvl": 66,
"ilvl": 74,
@@ -2408,7 +2408,7 @@
"category": "Patch 1.10+"
},
"308": {
"name": "Jadetalon",
"name": "Jade Talon",
"itembase": "7wb",
"req_lvl": 66,
"ilvl": 74,
@@ -2416,7 +2416,7 @@
"category": "Patch 1.10+"
},
"309": {
"name": "Shadowdancer",
"name": "Shadow Dancer",
"itembase": "uhb",
"req_lvl": 71,
"ilvl": 79,
@@ -2424,7 +2424,7 @@
"category": "Patch 1.10+"
},
"310": {
"name": "Cerebus",
"name": "Cerebus' Bite",
"itembase": "drb",
"req_lvl": 63,
"ilvl": 71,
@@ -2440,7 +2440,7 @@
"category": "Patch 1.10+"
},
"312": {
"name": "Souldrain",
"name": "Soul Drainer",
"itembase": "umg",
"req_lvl": 74,
"ilvl": 82,
@@ -2448,7 +2448,7 @@
"category": "Patch 1.10+"
},
"313": {
"name": "Runemaster",
"name": "Rune Master",
"itembase": "72a",
"req_lvl": 72,
"ilvl": 80,
@@ -2456,7 +2456,7 @@
"category": "Patch 1.10+"
},
"314": {
"name": "Deathcleaver",
"name": "Death Cleaver",
"itembase": "7wa",
"req_lvl": 70,
"ilvl": 78,
@@ -2488,7 +2488,7 @@
"category": "Patch 1.10+"
},
"319": {
"name": "Wisp",
"name": "Wisp Projector",
"itembase": "rin",
"req_lvl": 76,
"ilvl": 84,
@@ -2552,7 +2552,7 @@
"category": "Patch 1.10+"
},
"327": {
"name": "Spiritkeeper",
"name": "Spirit Keeper",
"itembase": "drd",
"req_lvl": 67,
"ilvl": 75,
@@ -2576,7 +2576,7 @@
"category": "Patch 1.10+"
},
"330": {
"name": "Darkforge Spawn",
"name": "Darkforce Spawn",
"itembase": "nef",
"req_lvl": 64,
"ilvl": 72,
@@ -2592,7 +2592,7 @@
"category": "Patch 1.10+"
},
"332": {
"name": "Bloodraven's Charge",
"name": "Blood Raven's Charge",
"itembase": "amb",
"req_lvl": 71,
"ilvl": 79,
@@ -2608,7 +2608,7 @@
"category": "Patch 1.10+"
},
"334": {
"name": "Shadowkiller",
"name": "Shadow Killer",
"itembase": "7cs",
"req_lvl": 78,
"ilvl": 85,
@@ -2664,7 +2664,7 @@
"category": "Patch 1.10+"
},
"342": {
"name": "Steelpillar",
"name": "Steel Pillar",
"itembase": "7p7",
"req_lvl": 69,
"ilvl": 77,
@@ -2704,7 +2704,7 @@
"category": "Patch 1.10+"
},
"348": {
"name": "Steel Carapice",
"name": "Steel Carapace",
"itembase": "uul",
"req_lvl": 66,
"ilvl": 74,
@@ -2744,7 +2744,7 @@
"category": "Patch 1.10+"
},
"354": {
"name": "Fathom",
"name": "Death's Fathom",
"itembase": "obf",
"req_lvl": 73,
"ilvl": 81,
@@ -2840,7 +2840,7 @@
"category": "Patch 1.10+"
},
"367": {
"name": "Eschuta's temper",
"name": "Eschuta's Temper",
"itembase": "obc",
"req_lvl": 72,
"ilvl": 80,
@@ -2904,7 +2904,7 @@
"category": "Patch 1.10+"
},
"376": {
"name": "Verdugo's Hearty Cord",
"name": "Verdungo's Hearty Cord",
"itembase": "umc",
"req_lvl": 63,
"ilvl": 71,
@@ -2920,7 +2920,7 @@
"category": "Patch 1.10+"
},
"379": {
"name": "Giantskull",
"name": "Giant Skull",
"itembase": "uh9",
"req_lvl": 65,
"ilvl": 73,
@@ -2928,7 +2928,7 @@
"category": "Patch 1.10+"
},
"380": {
"name": "Ironward",
"name": "Astreon's Iron Ward",
"itembase": "7ws",
"req_lvl": 60,
"ilvl": 68,
@@ -2968,7 +2968,7 @@
"category": "Patch 1.10+"
},
"385": {
"name": "Earthshifter",
"name": "Earth Shifter",
"itembase": "7gm",
"req_lvl": 69,
"ilvl": 77,
@@ -2976,7 +2976,7 @@
"category": "Patch 1.10+"
},
"386": {
"name": "Wraithflight",
"name": "Wraith Flight",
"itembase": "7gl",
"req_lvl": 76,
"ilvl": 84,
@@ -3000,7 +3000,7 @@
"category": "Patch 1.10+"
},
"389": {
"name": "The Reedeemer",
"name": "The Redeemer",
"itembase": "7sc",
"req_lvl": 72,
"ilvl": 80,
@@ -3008,7 +3008,7 @@
"category": "Patch 1.10+"
},
"390": {
"name": "Headhunter's Glory",
"name": "Head Hunter's Glory",
"itembase": "ush",
"req_lvl": 75,
"ilvl": 83,

45
d2warehouse/db.py Normal file
View File

@@ -0,0 +1,45 @@
import atexit
import os
import sqlite3
_schema_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "schema.sql")
_db = None
_path = "items.sqlite3"
def set_db_path(f: str) -> None:
global _path
_path = f
def get_db() -> sqlite3.Connection:
global _db
if not _db:
_db = sqlite3.connect(
_path,
detect_types=sqlite3.PARSE_DECLTYPES,
)
_db.row_factory = sqlite3.Row
return _db
@atexit.register
def close_db() -> None:
global _db
if _db:
_db.close()
_db = None
def init_db() -> None:
db = get_db()
count = db.execute("SELECT count(*) FROM sqlite_master").fetchone()[0]
if count == 0:
create_db()
def create_db() -> None:
db = get_db()
with open(_schema_path, encoding="utf-8") as f:
db.executescript(f.read())

View File

@@ -16,4 +16,5 @@
# Mercator. If not, see <https://www.gnu.org/licenses/>.
STASH_TAB_MAGIC = b"\x55\xAA\x55\xAA"
STASH_TAB_VERSION = 99
ITEM_DATA_MAGIC = b"JM"

View File

@@ -62,7 +62,7 @@ decode_tree = decodetree(code)
def decode(bits: bitarray, n) -> tuple[str, int]:
text = "".join(itertools.islice(bits.iterdecode(decode_tree), n))
text = "".join(itertools.islice(bits.decode(decode_tree), n))
length = len(encode(text))
return text, length

View File

@@ -17,11 +17,12 @@
import json
import os
import re
from typing import Optional
from bitarray import bitarray
from bitarray.util import int2ba
from dataclasses import dataclass
from enum import Enum
from enum import IntEnum
from d2warehouse.fileformat import STASH_TAB_VERSION
from d2warehouse.db import get_db
_data_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
_basetype_map = None
@@ -29,9 +30,11 @@ _stats_map = None
_unique_map = None
_set_item_map = None
_runeword_map = None
_affix_map = None
_skills_map = None
class Quality(Enum):
class Quality(IntEnum):
LOW = 1
NORMAL = 2
HIGH = 3
@@ -45,7 +48,7 @@ class Quality(Enum):
return self.name.capitalize()
class LowQualityType(Enum):
class LowQualityType(IntEnum):
CRUDE = 0
CRACKED = 1
DAMAGED = 2
@@ -60,7 +63,7 @@ class Stat:
id: int | None = None # TODO: These 3 should probably not be optional
values: list[int] | None = None
parameter: int | None = None
text: str | None = None
text: str | None = None # TODO: Make this a property
def print(self, indent=5):
print(" " * indent, str(self))
@@ -75,8 +78,18 @@ class Stat:
for val in self.values:
subst_text = subst_text.replace("#", str(val), 1)
if param:
subst_text = re.sub(r"\[[^\]]*\]", str(param), subst_text, 1)
subst_text = self.try_add_skill_text(subst_text)
return subst_text
def try_add_skill_text(self, subst_text: str) -> str | None:
if self.id == 107: # +X to [Skill] ([Class] only)
return re.sub(r"\[[^\]]*\]", lookup_skill_name(self.parameter), subst_text, 1)
elif self.id == 188: # +X to [Skill]
if '[' in subst_text:
return subst_text[:subst_text.find('[')] + lookup_random_skill_tab(self.parameter)
return re.sub(r"\[[^\]]*\]", lookup_random_skill_tab(self.parameter), subst_text, 1)
else:
return re.sub(r"\[[^\]]*\]", str(self.parameter), subst_text, 1)
def txtbits(bits: bitarray) -> str:
@@ -88,6 +101,7 @@ def txtbits(bits: bitarray) -> str:
@dataclass
class Item:
raw_data: bytes
raw_version: int
is_identified: bool
is_socketed: bool
is_beginner: bool
@@ -115,10 +129,74 @@ class Item:
defense: int | None = None
durability: int | None = None
max_durability: int | None = None
sockets: list[Optional["Item"]] | None = None
sockets: int | None = None
socketed_items: list["Item"] | None = None
quantity: int | None = None
stats: list[Stat] | None = None
@property
def basename(self) -> str:
return lookup_basetype(self.code)["name"]
@property
def name(self) -> str:
match self.quality:
case Quality.LOW:
return f"{self.low_quality} {self.basename}"
case Quality.NORMAL | None:
return self.basename
case Quality.HIGH:
return f"Superior {self.basename}"
case Quality.MAGIC:
return f"<prefix> {self.basename} <suffix>"
case Quality.SET:
assert self.set_id is not None
return lookup_set_item(self.set_id)["name"]
case Quality.RARE:
# FIXME
return "<rare> <name>"
case Quality.UNIQUE:
assert self.unique_id is not None
return lookup_unique(self.unique_id)["name"]
case Quality.CRAFTED:
# FIXME
return "<crafted> <name>"
case _:
# TODO: In 3.11 replace this with assert_never
assert False, "Should be unreachable"
@property
def color(self) -> str:
if self.is_runeword:
return "runeword"
match self.quality:
case Quality.LOW:
return "low"
case Quality.NORMAL | None:
return "normal"
case Quality.HIGH:
return "normal"
case Quality.MAGIC:
return "magic"
case Quality.SET:
return "set"
case Quality.RARE:
return "rare"
case Quality.UNIQUE:
return "unique"
case Quality.CRAFTED:
return "crafted"
case _:
# TODO: In 3.11 replace this with assert_never
assert False, "Should be unreachable"
def raw(self):
parts = [self.raw_data]
if self.socketed_items:
for item in self.socketed_items:
parts.append(item.raw_data)
return b"".join(parts)
def print(self, indent=5, with_raw=False):
properties = []
base_name = lookup_basetype(self.code)["name"]
@@ -165,12 +243,9 @@ class Item:
f"Durability: {self.durability} out of {self.max_durability}",
)
if self.is_socketed:
print(" " * indent, f"{len(self.sockets)} sockets:")
for socket in self.sockets:
if socket:
socket.print(indent + 4)
else:
print(" " * (indent + 4), "Empty")
print(" " * indent, f"{len(self.socketed_items)}/{self.sockets} sockets:")
for socket in self.socketed_items:
socket.print(indent + 4)
if self.quantity:
print(" " * indent, f"Quantity: {self.quantity}")
if self.stats:
@@ -201,6 +276,240 @@ class Item:
base = lookup_basetype(self.code)
return base["width"], base["height"]
def requirements(self) -> dict:
base = lookup_basetype(self.code)
# TODO: How is class requirements determined on basetypes?
reqs = {
"lvl": base["req_lvl"],
"dex": base["req_dex"],
"str": base["req_str"],
"class": None,
}
# TODO: Can implicit mod end up increasing requirements?
if self.quality == Quality.UNIQUE:
reqs["lvl"] = lookup_unique(self.unique_id)["req_lvl"]
elif self.quality == Quality.SET:
reqs["lvl"] = lookup_set_item(self.set_id)["req_lvl"]
elif self.is_runeword:
# TODO: What affects runeword level? Only the sockets?
pass
elif self.quality in [Quality.MAGIC, Quality.RARE, Quality.CRAFTED]:
for m, id in [(False, id) for id in self.suffixes] + [
(True, id) for id in self.prefixes
]:
if id == 0:
continue
affix = lookup_affix(id, m)
reqs["lvl"] = max(reqs["lvl"], affix["req_lvl"])
if affix["req_class"]:
reqs["class"] = affix["req_class"]
if self.socketed_items:
for socket_item in self.socketed_items:
socket_reqs = socket_item.requirements()
reqs["lvl"] = max(reqs["lvl"], socket_reqs["lvl"])
return reqs
def write_to_db(self, socketed_into=None, commit=True, db=None) -> int:
if db is None:
db = get_db()
name = lookup_basetype(self.code)["name"]
# FIXME: handle magic & rare names
if self.is_runeword:
name = lookup_runeword(self.runeword_id)["name"]
elif self.quality == Quality.SET:
name = lookup_set_item(self.set_id)["name"]
elif self.quality == Quality.UNIQUE:
name = lookup_unique(self.unique_id)["name"]
set_name = (
lookup_set_item(self.set_id)["set"] if self.quality == Quality.SET else None
)
req = self.requirements()
cur = db.cursor()
cur.execute(
"""INSERT INTO item (itembase_name, socketed_into, raw_data, raw_version,
is_identified, is_socketed, is_beginner, is_simple, is_ethereal,
is_personalized, is_runeword, code, req_lvl)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
lookup_basetype(self.code)["name"],
socketed_into,
self.raw_data,
self.raw_version,
self.is_identified,
self.is_socketed,
self.is_beginner,
self.is_simple,
self.is_ethereal,
self.is_personalized,
self.is_runeword,
self.code,
req["lvl"],
),
)
item_id = cur.lastrowid
if not self.is_simple:
cur.execute(
"""INSERT INTO item_extra (item_id, item_name,
set_name, uid, lvl, quality, graphic, implicit, low_quality, set_id,
unique_id, nameword1, nameword2, runeword_id, personal_name,
defense, durability, max_durability, sockets, quantity, req_str,
req_dex, req_class)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?)""",
(
item_id,
name,
set_name,
self.uid,
self.lvl,
int(self.quality) if self.quality else None,
self.graphic,
self.implicit,
int(self.low_quality) if self.low_quality else None,
self.set_id,
self.unique_id,
self.nameword1,
self.nameword2,
self.runeword_id,
self.personal_name,
self.defense,
self.durability,
self.max_durability,
self.sockets,
self.quantity,
req["str"],
req["dex"],
req["class"],
),
)
if self.quality in [Quality.MAGIC, Quality.RARE, Quality.CRAFTED]:
for prefix, id in [(False, id) for id in self.suffixes] + [
(True, id) for id in self.prefixes
]:
db.execute(
"INSERT INTO item_affix (item_id, prefix, affix_id) VALUES (?, ?, ?)",
(item_id, prefix, id),
)
if self.stats:
for stat in self.stats:
db.execute(
"""INSERT INTO item_stat (item_id, stat, value1, value2, value3, parameter)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
item_id,
stat.id,
stat.values[0] if len(stat.values) > 0 else None,
stat.values[1] if len(stat.values) > 1 else None,
stat.values[2] if len(stat.values) > 2 else None,
stat.parameter,
),
)
if self.socketed_items:
for socket_item in self.socketed_items:
socket_item.write_to_db(socketed_into=item_id, commit=False, db=db)
if commit:
db.commit()
return item_id
def load_from_db(id: int, db=None) -> "Item":
if db is None:
db = get_db()
row = db.execute(
"""SELECT raw_data, raw_version, is_identified, is_socketed,
is_beginner, is_simple, is_ethereal, is_personalized, is_runeword, code,
uid, lvl, quality, graphic, implicit, low_quality, set_id, unique_id,
nameword1, nameword2, runeword_id, personal_name, defense, durability,
max_durability, sockets, quantity
FROM item LEFT JOIN item_extra ON id = item_id WHERE id = ?""",
(id,),
).fetchone()
if row["raw_version"] != STASH_TAB_VERSION:
raise RuntimeError("Can not load item, the raw version is not supported")
item = Item(
raw_data=row["raw_data"],
raw_version=row["raw_version"],
is_identified=bool(row["is_identified"]),
is_socketed=bool(row["is_socketed"]),
is_beginner=bool(row["is_beginner"]),
is_simple=bool(row["is_simple"]),
is_ethereal=bool(row["is_ethereal"]),
is_personalized=bool(row["is_personalized"]),
is_runeword=bool(row["is_runeword"]),
pos_x=0,
pos_y=0,
code=row["code"],
uid=row["uid"],
lvl=row["lvl"],
quality=Quality(row["quality"]) if row["quality"] else None,
graphic=row["graphic"],
implicit=row["implicit"],
low_quality=LowQualityType(row["low_quality"])
if row["low_quality"]
else None,
set_id=row["set_id"],
unique_id=row["unique_id"],
nameword1=row["nameword1"],
nameword2=row["nameword2"],
runeword_id=row["runeword_id"],
personal_name=row["personal_name"],
defense=row["defense"],
durability=row["durability"],
max_durability=row["max_durability"],
sockets=row["sockets"],
quantity=row["quantity"],
)
if item.quality in [Quality.MAGIC, Quality.RARE, Quality.CRAFTED]:
rows = db.execute(
"SELECT prefix, affix_id FROM item_affix WHERE item_id = ?", (id,)
)
item.prefixes = []
item.suffixes = []
for row in rows:
if row["prefix"]:
item.prefixes.append(row["affix_id"])
else:
item.suffixes.append(row["affix_id"])
if item.is_socketed:
item.socketed_items = []
rows = db.execute(
"SELECT id FROM item WHERE socketed_into = ?", (id,)
).fetchall()
if len(rows) > 0:
for row in rows:
socket_item = Item.load_from_db(row["id"], db=db)
socket_item.pos_x = len(item.socketed_items)
item.socketed_items.append(socket_item)
rows = db.execute(
"SELECT stat, value1, value2, value3, parameter FROM item_stat WHERE item_id = ?",
(id,),
).fetchall()
if len(rows) > 0:
item.stats = []
for row in rows:
values = []
for i in range(1, 4):
if row[f"value{i}"] is not None:
values.append(row[f"value{i}"])
stat = Stat(id=row["stat"], values=values, parameter=row["parameter"])
stat_data = lookup_stat(stat.id)
stat.text = stat_data["text"]
item.stats.append(stat)
return item
def lookup_basetype(code: str) -> dict:
global _basetype_map
@@ -240,3 +549,65 @@ def lookup_runeword(id: int) -> dict:
with open(os.path.join(_data_path, "runewords.json")) as f:
_runeword_map = json.load(f)
return _runeword_map[str(id)]
def lookup_affix(id: int, prefix: bool) -> dict:
global _affix_map
if _affix_map is None:
with open(os.path.join(_data_path, "affixes.json")) as f:
_affix_map = json.load(f)
return _affix_map["prefixes" if prefix else "suffixes"][str(id)]
def _get_skills_map():
global _skills_map
if _skills_map is None:
with open(os.path.join(_data_path, "skills.json")) as f:
_skills_map = json.load(f)
return _skills_map
def lookup_skill_name(id: int) -> str:
# FIXME: This hackish way of calculating the key is because we directly index into local/lng/strings/skills.json
# but the actual ID points to global/excel/skills.txt
skills_map = _get_skills_map()
try:
try:
return skills_map[f"Skillname{id + 1}"]
except KeyError:
return skills_map[f"skillname{id}"]
except KeyError:
return f"<Invalid key: skillname{id} or Skillname{id + 1}>"
def lookup_random_skill_tab(id: int) -> str:
# (ClassId * 8) is the id of the first tab for that class
skills_map = _get_skills_map()
class_name = lookup_class(int(id / 8))
two_letter_class_code = lookup_class(int(id / 8))[:2]
tab_index = 3 - id % 8 # for some reason local/lng/strings/skills.json index backwards (0 -> 3, 1 -> 2, ...)
try:
return skills_map[f"SkillCategory{two_letter_class_code}{tab_index}"] + f" ({class_name} only)"
except KeyError:
return f"<Invalid random skill tab: {id}>"
def lookup_class(id: int) -> str:
match id:
case 0:
return "Amazon"
case 1:
return "Sorceress"
case 2:
return "Necromancer"
case 3:
return "Paladin"
case 4:
return "Barbarian"
case 5:
return "Druid"
case 6:
return "Assassin"
case _:
# TODO: In 3.11 replace this with assert_never
assert False, f"{id} Should be unreachable"

View File

@@ -27,7 +27,7 @@ from d2warehouse.item import (
lookup_stat,
)
import d2warehouse.huffman as huffman
from d2warehouse.fileformat import STASH_TAB_MAGIC, ITEM_DATA_MAGIC
from d2warehouse.fileformat import STASH_TAB_MAGIC, STASH_TAB_VERSION, ITEM_DATA_MAGIC
class ParseError(RuntimeError):
@@ -82,7 +82,7 @@ def parse_stash_tab(data: bytes) -> tuple[bytes, StashTab]:
if unknown != 1:
ParseError("Unknown stash tab field is not 1")
if version != 99:
if version != STASH_TAB_VERSION:
ParseError(f"Unsupported stash tab version ({version} instead of 99)")
tab = StashTab()
@@ -133,6 +133,7 @@ def parse_item(data: bytes) -> tuple[bytes, Item]:
simple_byte_sz = int((sockets_end + 7) / 8)
item = Item(
data[:simple_byte_sz],
STASH_TAB_VERSION,
is_identified,
is_socketed,
is_beginner,
@@ -178,7 +179,8 @@ def parse_item(data: bytes) -> tuple[bytes, Item]:
itembase_end += personalized_end
if item.is_socketed:
sockets_count = ba2int(bits[itembase_end : itembase_end + 4])
item.sockets = ba2int(bits[itembase_end : itembase_end + 4])
item.socketed_items = []
sockets_end = itembase_end + 4
else:
sockets_end = itembase_end
@@ -193,10 +195,9 @@ def parse_item(data: bytes) -> tuple[bytes, Item]:
# Parse out sockets if any exist on the item
if item.is_socketed:
item.sockets = [None] * sockets_count
for i in range(0, filled_sockets):
remaining_data, socket = parse_item(remaining_data)
item.sockets[i] = socket
remaining_data, socket_item = parse_item(remaining_data)
item.socketed_items.append(socket_item)
return remaining_data, item
@@ -267,14 +268,20 @@ def parse_set_data(bits: bitarray, item: Item) -> tuple[Item, int]:
def parse_rare_data(bits: bitarray, item: Item) -> tuple[Item, int]:
item.nameword1 = ba2int(bits[0:8])
item.nameword2 = ba2int(bits[8:16])
affixes = []
item.prefixes = []
item.suffixes = []
ptr = 16
for _ in range(0, 6):
for _ in range(0, 3):
# Prefix
(affix, sz) = parse_affix(bits[ptr:])
ptr += sz
affixes.append(affix)
item.prefixes = [affix for affix in affixes[0:3] if affix is not None]
item.suffixes = [affix for affix in affixes[3:6] if affix is not None]
if affix:
item.prefixes.append(affix)
# Suffix
(affix, sz) = parse_affix(bits[ptr:])
ptr += sz
if affix:
item.suffixes.append(affix)
return item, ptr

90
d2warehouse/schema.sql Normal file
View File

@@ -0,0 +1,90 @@
--DROP TABLE IF EXISTS item_stat;
--DROP TABLE IF EXISTS item_affix;
--DROP TABLE IF EXISTS item_extra;
--DROP TABLE IF EXISTS item;
CREATE TABLE item (
id INTEGER PRIMARY KEY,
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted TIMESTAMP DEFAULT NULL,
-- Nuked: if the item has been removed from storage & user indicated he does not
-- want to count it as potentially in his possession any longer
nuked BOOLEAN DEFAULT NULL,
-- Names: simple items have only a name equal to their base name, most non simple
-- items have two names: the base name and item_extra.item_name.
itembase_name TEXT NOT NULL,
socketed_into INTEGER DEFAULT NULL,
req_lvl INTEGER DEFAULT 0,
-- The following fields match the fields of the item object in item.py
raw_data BLOB NOT NULL,
raw_version INTEGER NOT NULL,
is_identified BOOLEAN NOT NULL,
is_socketed BOOLEAN NOT NULL,
is_beginner BOOLEAN NOT NULL,
is_simple BOOLEAN NOT NULL,
is_ethereal BOOLEAN NOT NULL,
is_personalized BOOLEAN NOT NULL,
is_runeword BOOLEAN NOT NULL,
code TEXT NOT NULL,
FOREIGN KEY (socketed_into) REFERENCES item (id)
);
-- Add an index for "... WHERE deletion IS NULL"
CREATE INDEX item_deletion_partial ON item (deleted) WHERE deleted IS NULL;
CREATE TABLE item_extra (
item_id INTEGER PRIMARY KEY,
item_name TEXT DEFAULT NULL,
set_name TEXT DEFAULT NULL,
req_str INTEGER DEFAULT 0,
req_dex INTEGER DEFAULT 0,
req_class TEXT DEFAULT NULL,
-- The following fields match the fields of the item object in item.py
uid INTEGER DEFAULT NULL,
lvl INTEGER DEFAULT NULL,
quality INTEGER DEFAULT NULL CHECK(1 <= quality AND quality <= 8),
graphic INTEGER DEFAULT NULL,
implicit INTEGER DEFAULT NULL,
low_quality INTEGER DEFAULT NULL CHECK(0 <= low_quality AND low_quality <= 3),
set_id INTEGER DEFAULT NULL,
unique_id INTEGER DEFAULT NULL,
nameword1 INTEGER DEFAULT NULL,
nameword2 INTEGER DEFAULT NULL,
runeword_id INTEGER DEFAULT NULL,
personal_name TEXT DEFAULT NULL,
defense INTEGER DEFAULT NULL,
durability INTEGER DEFAULT NULL,
max_durability INTEGER DEFAULT NULL,
sockets INTEGER DEFAULT NULL, -- number of sockets; see item.socketed_into
quantity INTEGER DEFAULT NULL,
-- stats: list[Stat] | None = None => see table 'item_stat'
FOREIGN KEY (item_id) REFERENCES item (id)
);
CREATE INDEX item_extra_item_id ON item_extra (item_id);
CREATE TABLE item_stat (
id INTEGER PRIMARY KEY,
item_id INTEGER NOT NULL,
stat INTEGER NOT NULL,
value1 INTEGER DEFAULT NULL,
value2 INTEGER DEFAULT NULL,
value3 INTEGER DEFAULT NULL,
parameter INTEGER DEFAULT NULL,
FOREIGN KEY (item_id) REFERENCES item (id)
);
CREATE INDEX item_stat_item_id ON item_stat (item_id);
CREATE INDEX item_stat_stat ON item_stat (stat);
CREATE TABLE item_affix (
id INTEGER PRIMARY KEY,
item_id INTEGER NOT NULL,
prefix BOOLEAN NOT NULL,
affix_id INTEGER NOT NULL,
FOREIGN KEY (item_id) REFERENCES item (id)
);
CREATE INDEX item_affix_item_id ON item_affix (item_id);

View File

@@ -34,6 +34,17 @@ class Stash:
def raw(self) -> bytes:
return b"".join(tab.raw() for tab in self.tabs)
def add(self, item: Item) -> None:
for tab in self.tabs:
try:
tab.add(item)
return
except StashFullError:
pass
raise StashFullError(
"Could not locate an open spot in the stash to add the item"
)
class StashTab:
def __init__(self) -> None:
@@ -44,7 +55,7 @@ class StashTab:
def raw(self) -> bytes:
"""Get the computed raw representation of the stash"""
item_raw = b"".join(item.raw_data for item in self.items)
item_raw = b"".join(item.raw() for item in self.items)
raw_length = len(item_raw) + 0x44
return (
STASH_TAB_MAGIC

View File

@@ -0,0 +1,55 @@
import os
import tempfile
import unittest
from d2warehouse.db import close_db, create_db, get_db, set_db_path
from d2warehouse.item import Item
from d2warehouse.parser import parse_item
class DbTest(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls._fd, cls._path = tempfile.mkstemp()
set_db_path(cls._path)
create_db()
@classmethod
def tearDownClass(cls):
close_db()
os.close(cls._fd)
os.unlink(cls._path)
def test_runeword_lore(self):
data = bytes.fromhex(
"10088004050054c637f1073af4558697412981070881506049e87f005516fb134582ff1000a0003500e07cbb001000a0003504e07c9800"
)
_, item = parse_item(data)
db = get_db()
db_id = item.write_to_db(db=db)
loaded_item = Item.load_from_db(db_id, db=db)
self.assertEqual(item, loaded_item)
# Check that requirement was written properly
reqs = db.execute(
"SELECT req_lvl, req_str, req_dex, req_class FROM item JOIN item_extra ON id = item_id WHERE id = ?",
(db_id,),
).fetchone()
expected_reqs = item.requirements()
self.assertEqual(reqs["req_lvl"], expected_reqs["lvl"])
self.assertEqual(reqs["req_str"], expected_reqs["str"])
self.assertEqual(reqs["req_dex"], expected_reqs["dex"])
self.assertEqual(reqs["req_class"], expected_reqs["class"])
def test_empty_sockets(self):
# superior armor with empty sockets
data = bytes.fromhex("10088000050014df175043b1b90cc38d80e3834070b004f41f")
_, item = parse_item(data)
db = get_db()
db_id = item.write_to_db(db=db)
loaded_item = Item.load_from_db(db_id, db=db)
self.assertEqual(loaded_item.sockets, 2)
self.assertEqual(len(loaded_item.socketed_items), 0)
self.assertEqual(item, loaded_item)

View File

@@ -1,6 +1,6 @@
import unittest
from d2warehouse.parser import parse_item
from d2warehouse.item import Quality, lookup_runeword
from d2warehouse.item import Quality, lookup_affix, lookup_runeword
class ParseItemTest(unittest.TestCase):
@@ -68,7 +68,8 @@ class ParseItemTest(unittest.TestCase):
self.assertEqual(data, b"")
self.assertEqual(item.quality, Quality.HIGH)
self.assertEqual(len(item.stats), 2)
self.assertEqual(len(item.sockets), 2)
self.assertEqual(item.sockets, 2)
self.assertEqual(len(item.socketed_items), 0)
def test_ed_max(self):
# test bugfix for https://gitlab.com/omicron-oss/d2warehouse/-/issues/1
@@ -89,11 +90,9 @@ class ParseItemTest(unittest.TestCase):
data, item = parse_item(data)
self.assertEqual(data, b"")
self.assertTrue(item.is_runeword)
self.assertEqual(len(item.sockets), 2)
item.sockets[0].print()
item.sockets[1].print()
self.assertEqual(item.sockets[0].code, "r09")
self.assertEqual(item.sockets[1].code, "r12")
self.assertEqual(item.sockets, 2)
self.assertEqual(item.socketed_items[0].code, "r09")
self.assertEqual(item.socketed_items[1].code, "r12")
rw = lookup_runeword(item.runeword_id)
self.assertEqual(rw["name"], "Lore")
self.assertEqual(str(item.stats[4]), "+1 to All Skills") # runeword stat
@@ -214,3 +213,38 @@ class ParseItemTest(unittest.TestCase):
data, _ = parse_item(data)
self.assertEqual(data, b"")
def test_affixes(self):
data = bytes.fromhex(
"1000800005d0f4aa09173a36bf0723542d351ae4236acbd27000c30201a1052810208cf1241b4c50fc07"
)
data, item = parse_item(data)
self.assertEqual(data, b"")
req = item.requirements()
self.assertEqual(req["lvl"], 4)
# +8 stamina
self.assertAlmostEqual(lookup_affix(item.prefixes[0], True)["name"], "Rugged")
# 16% ed
self.assertAlmostEqual(lookup_affix(item.prefixes[1], True)["name"], "Sturdy")
# 10% fhr
self.assertAlmostEqual(
lookup_affix(item.suffixes[0], False)["name"], "of Balance"
)
# +1 dex
self.assertAlmostEqual(
lookup_affix(item.suffixes[1], False)["name"], "of Dexterity"
)
# Lightning bolt on hit
self.assertAlmostEqual(
lookup_affix(item.suffixes[2], False)["name"], "of Charged Shield"
)
def test_cathans_rule(self):
# Cathan's Rule: Only source of fire-max stat
data = bytes.fromhex(
"10008000050054c90448ad74276f910150448c180afc24ff1348fd3f212d85b415d25a48fb0f"
)
data, item = parse_item(data)
self.assertEqual(data, b"")
self.assertEqual(str(item.stats[0]), "+10 to Maximum Fire Damage")

View File

@@ -84,3 +84,18 @@ class StashTest(unittest.TestCase):
self.assertEqual(len(new_stash.tabs[0].items), 3)
self.assertEqual(len(new_stash.tabs[1].items), 1)
self.assertEqual(len(new_stash.tabs[2].items), 25)
def test_gemmed_raw(self):
data = bytes.fromhex(
"55aa55aa0100000063000000a40100006200000000000000000000000000"
"000000000000000000000000000000000000000000000000000000000000"
"000000004a4d010010088000050094a459a496629918020a484890ff1000"
"a0003500e07c6f0355aa55aa0100000063000000391b0000440000000000"
"000000000000000000000000000000000000000000000000000000000000"
"0000000000000000000000004a4d000055aa55aa01000000630000003905"
"000044000000000000000000000000000000000000000000000000000000"
"00000000000000000000000000000000000000004a4d0000"
)
stash = parse_stash(data)
rebuilt = stash.raw()
self.assertEqual(data, rebuilt)

View File

@@ -18,6 +18,8 @@ requires-python = ">=3.10"
license = {text = "GPLv3 License"}
dependencies = [
"bitarray",
"flask",
"psutil",
]
dynamic = ["version"]
@@ -44,7 +46,7 @@ d2test = "d2warehouse.test:main"
version = {attr = "d2warehouse.__version__"}
[tool.setuptools.package-data]
d2warehouse = ["data/*.json"]
d2warehouse = ["data/*.json", "schema.sql"]
[tool.pytest.ini_options]
addopts = "--cov --cov-report html --cov-report term"