Compare commits

24 Commits

Author SHA1 Message Date
31e57da117 Move currency out of the storage page in the web UI
This brings the currency page in line with the grail page.
2025-10-02 22:40:18 +02:00
2baa43db20 Add rudimentary grail tracker for sets and uniques 2025-10-02 22:40:04 +02:00
3e2c481f6f Add basic menu to the web UI 2025-10-02 19:19:05 +02:00
1cb9ff63e7 Fix showing/counting items that are socketed into other items 2025-10-02 19:18:57 +02:00
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
31 changed files with 4963 additions and 2550 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
```

View File

@@ -11,12 +11,14 @@ 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": row["levelreq"],
"req_lvl": 0 if len(row["levelreq"]) == 0 else int(row["levelreq"]),
"req_class": None if len(row["class"]) == 0 else row["class"],
}
@@ -24,12 +26,14 @@ 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": row["levelreq"],
"req_lvl": 0 if len(row["levelreq"]) == 0 else int(row["levelreq"]),
"req_class": None if len(row["class"]) == 0 else row["class"],
}

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()

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

@@ -0,0 +1,444 @@
import hashlib
from flask import Flask, redirect, abort, render_template, request
from pathlib import Path
import shutil
from datetime import datetime
import json
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 AND socketed_into 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 AND socketed_into 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 AND socketed_into 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 and socketed_into 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 and socketed_into 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 socketed_into 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 socketed_into IS NULL AND quality = ?",
(int(Quality.SET),),
)
elif category == "misc":
q = db.execute(
"SELECT id FROM item "
"WHERE deleted IS NULL AND socketed_into 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,
category=category,
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 AND socketed_into IS NULL",
(code,),
).fetchone()[0],
"name": lookup_basetype(code)["name"],
}
return currencies
@app.route("/currency/<stash_name>")
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
)
def load_uniques_data():
"""Return a sorted dictionary of unique item ids indexed by names."""
uniques_path = Path(__file__).resolve().parent.parent / "data/uniques.json"
with uniques_path.open() as f:
data = json.load(f)
name_to_id = {v["name"]: int(k) for k, v in data.items()}
del name_to_id["Amulet of the Viper"]
del name_to_id["Staff of Kings"]
del name_to_id["Horadric Staff"]
del name_to_id["Khalim's Flail"]
del name_to_id["Khalim's Will"]
del name_to_id["Hell Forge Hammer"]
return {name: name_to_id[name] for name in sorted(name_to_id.keys())}
def load_sets_data():
"""Return a sorted dictionary of unique item ids indexed by names."""
uniques_path = Path(__file__).resolve().parent.parent / "data/sets.json"
with uniques_path.open() as f:
data = json.load(f)
name_to_id = {v["name"]: int(k) for k, v in data.items()}
return {name: name_to_id[name] for name in sorted(name_to_id.keys())}
all_uniques = load_uniques_data()
all_sets = load_sets_data()
def get_found_uniques(db):
rows = db.execute(
"SELECT DISTINCT unique_id "
"FROM item INNER JOIN item_extra ON id = item_id "
"WHERE quality = 7 AND deleted IS NULL AND socketed_into IS NULL",
).fetchall()
return {row["unique_id"]: True for row in rows}
def get_found_sets(db):
rows = db.execute(
"SELECT DISTINCT set_id "
"FROM item INNER JOIN item_extra ON id = item_id "
"WHERE quality = 5 AND deleted IS NULL AND socketed_into IS NULL",
).fetchall()
return {row["set_id"]: True for row in rows}
@app.route("/grail/<stash_name>")
def storage_grail(stash_name: str):
if stash_name not in DB_FILES:
abort(404)
db = get_stash_db(stash_name)
# Unique progress
found = get_found_uniques(db)
items = {name: found.get(id, False) for name, id in all_uniques.items()}
count = len(found)
total = len(all_uniques)
uniques = {
"list": items,
"count": count,
"total": total,
"progress": count / total * 100,
}
# Set progress
found = get_found_sets(db)
items = {name: found.get(id, False) for name, id in all_sets.items()}
count = len(found)
total = len(all_sets)
sets = {
"list": items,
"count": count,
"total": total,
"progress": count / total * 100,
}
return render_template(
"grail.html",
uniques=uniques,
sets=sets,
)

View File

@@ -0,0 +1,20 @@
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;
});
}
function toggleCollected() {
const collected = document.querySelectorAll('.collected');
collected.forEach(item => {
item.classList.toggle('hidden');
});
}

View File

@@ -0,0 +1,150 @@
body {
background-color: #000;
font-size: large;
font-family: sans-serif;
color: rgb(240, 240, 240);
}
nav ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
background-color: #444;
}
nav a {
display: block;
padding: 1rem 1.5rem;
text-decoration: none;
color: rgb(240, 240, 240);
}
nav a:hover {
background-color: #333;
}
.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);
}
ul.grail {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 0.5rem 2rem;
}
ul.grail li {
flex: 0 0 15rem;
padding: 0.5rem;
width: 15rem;
display: flex;
}
.hidden {
display: none !important;
}
.collected {
background-color: #343;
}
.uncollected {
background-color: #433;
}

View File

@@ -0,0 +1,62 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Currency</title>
<link rel="stylesheet" href="/static/style.css" />
<head>
<body>
{% include "menu.html" %}
<nav>
<ul>
<li><a href="/storage/{{stash_name or 'softcore'}}">All</a></li>
<li><a href="/storage/{{stash_name or 'softcore'}}/uniques">Uniques</a></li>
<li><a href="/storage/{{stash_name or 'softcore'}}/sets">Sets</a></li>
<li><a href="/storage/{{stash_name or 'softcore'}}/currency">Currency</a></li>
</ul>
</nav>
<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,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Grail</title>
<link rel="stylesheet" href="/static/style.css" />
<script src="/static/helpers.js"></script>
<head>
<body>
{% include "menu.html" %}
<button type="button" onclick="toggleCollected()">Toggle collected</button>
<h1>Unique Items</h1>
Progress: {{uniques.count}}/{{uniques.total}} ({{uniques.progress | round(1)}}%)
<ul class="grail">
{% for name,collected in uniques.list.items() %}
<li class="{{ 'collected' if collected else 'uncollected' }}">{{name}}</li>
{% endfor %}
</ul>
<h1>Set Items</h1>
Progress: {{sets.count}}/{{sets.total}} ({{sets.progress | round(1)}}%)
<ul class="grail">
{% for name,collected in sets.list.items() %}
<li class="{{ 'collected' if collected else 'uncollected' }}">{{name}}</li>
{% endfor %}
</ul>
</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,29 @@
<!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>
{% include "menu.html" %}
<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,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Storage - {{category or 'all'}}</title>
<link rel="stylesheet" href="/static/style.css" />
<head>
<body>
{% include "menu.html" %}
<nav>
<ul>
<li><a href="/storage/{{stash_name or 'softcore'}}">All</a></li>
<li><a href="/storage/{{stash_name or 'softcore'}}/uniques">Uniques</a></li>
<li><a href="/storage/{{stash_name or 'softcore'}}/sets">Sets</a></li>
<li><a href="/storage/{{stash_name or 'softcore'}}/misc">Misc</a></li>
</ul>
</nav>
<form action="/storage/{{stash_name}}/take" method="POST">
<div>
<!-- TODO: Include item.html -->
There are {{ storage_items | length }} items.
{% 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>

View File

@@ -0,0 +1,8 @@
<nav>
<ul>
<li><a href="/stash/{{stash_name or 'softcore'}}">Stash</a></li>
<li><a href="/storage/{{stash_name or 'softcore'}}">Storage</a></li>
<li><a href="/grail/{{stash_name or 'softcore'}}">Grail</a></li>
<li><a href="/currency/{{stash_name or 'softcore'}}">Currency</a></li>
</ul>
</nav>

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,

View File

@@ -32,6 +32,13 @@ def close_db() -> None:
_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:

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,7 +17,6 @@
import json
import os
import re
from typing import Optional
from bitarray import bitarray
from bitarray.util import int2ba
from dataclasses import dataclass
@@ -31,6 +30,8 @@ _stats_map = None
_unique_map = None
_set_item_map = None
_runeword_map = None
_affix_map = None
_skills_map = None
class Quality(IntEnum):
@@ -77,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:
@@ -118,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"]
@@ -168,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:
@@ -204,7 +276,42 @@ class Item:
base = lookup_basetype(self.code)
return base["width"], base["height"]
def write_to_db(self, socketed_into=None, commit=True) -> int:
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:
@@ -218,13 +325,13 @@ class Item:
lookup_set_item(self.set_id)["set"] if self.quality == Quality.SET else None
)
db = get_db()
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
is_personalized, is_runeword, code, req_lvl)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
lookup_basetype(self.code)["name"],
socketed_into,
@@ -238,38 +345,46 @@ class Item:
self.is_personalized,
self.is_runeword,
self.code,
req["lvl"],
),
)
item_id = cur.lastrowid
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, quantity)
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.quantity,
),
)
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] + [
@@ -296,24 +411,25 @@ class Item:
),
)
if self.sockets:
for socket in self.sockets:
socket.write_to_db(socketed_into=item_id, commit=False)
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) -> "Item":
db = get_db()
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, quantity
FROM item INNER JOIN item_extra ON id = item_id WHERE id = ?""",
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:
@@ -349,6 +465,7 @@ class Item:
defense=row["defense"],
durability=row["durability"],
max_durability=row["max_durability"],
sockets=row["sockets"],
quantity=row["quantity"],
)
@@ -364,15 +481,16 @@ class Item:
else:
item.suffixes.append(row["affix_id"])
rows = db.execute(
"SELECT id FROM item WHERE socketed_into = ?", (id,)
).fetchall()
if len(rows) > 0:
item.sockets = []
for row in rows:
socket = Item.load_from_db(row["id"])
socket.pos_x = len(item.sockets)
item.sockets.append(socket)
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 = ?",
@@ -431,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

@@ -179,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
@@ -194,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
@@ -268,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

View File

@@ -1,7 +1,7 @@
DROP TABLE IF EXISTS item_stat;
DROP TABLE IF EXISTS item_affix;
DROP TABLE IF EXISTS item_extra;
DROP TABLE IF EXISTS item;
--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,
@@ -14,6 +14,7 @@ CREATE TABLE item (
-- 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,
@@ -31,13 +32,14 @@ CREATE TABLE item (
);
-- Add an index for "... WHERE deletion IS NULL"
CREATE INDEX item_deletion_partial ON item (deleted) WHERE deleted IS 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
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,
@@ -55,12 +57,13 @@ CREATE TABLE item_extra (
defense INTEGER DEFAULT NULL,
durability INTEGER DEFAULT NULL,
max_durability INTEGER DEFAULT NULL,
-- sockets: list[Optional["Item"]] | None = None => see item.socketed_into
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,
@@ -73,6 +76,7 @@ CREATE TABLE item_stat (
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 (
@@ -83,3 +87,4 @@ CREATE TABLE item_affix (
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

@@ -2,7 +2,7 @@ import os
import tempfile
import unittest
from d2warehouse.db import close_db, create_db, set_db_path
from d2warehouse.db import close_db, create_db, get_db, set_db_path
from d2warehouse.item import Item
from d2warehouse.parser import parse_item
@@ -26,6 +26,30 @@ class DbTest(unittest.TestCase):
)
_, item = parse_item(data)
db_id = item.write_to_db()
loaded_item = Item.load_from_db(db_id)
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"]