Compare commits
24 Commits
4da8e096fe
...
fix_item_c
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cb9ff63e7 | |||
| b8b79a0ea5 | |||
| 20fbdfdea9 | |||
| a8938264b0 | |||
| 8cca6e3464 | |||
| 9e7d69f667 | |||
| bd96f1e0ac | |||
| 85003665c9 | |||
| 78c22bc84f | |||
| f25180c3cf | |||
| 21cf1be326 | |||
| 9b288a39dc | |||
| 8b94171d22 | |||
| 9057f81d5f | |||
| 2373702a1f | |||
| 4788d0d641 | |||
| 21df28d7bb | |||
| 423e4368d7 | |||
| 5026f58eb8 | |||
| b73fd9c20e | |||
| 93b1ad25a6 | |||
| 88ecf7f700 | |||
| 7251805332 | |||
| 40276cb0b4 |
17
README.md
17
README.md
@@ -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
43
contrib/affixes.py
Normal 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")
|
||||
@@ -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
28
contrib/skills.py
Normal 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")
|
||||
@@ -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"},
|
||||
|
||||
@@ -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")
|
||||
|
||||
5
d2warehouse/app/__init__.py
Normal file
5
d2warehouse/app/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from d2warehouse.app.main import app
|
||||
|
||||
__all__ = [
|
||||
"app",
|
||||
]
|
||||
22
d2warehouse/app/db.py
Normal file
22
d2warehouse/app/db.py
Normal 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()
|
||||
358
d2warehouse/app/main.py
Normal file
358
d2warehouse/app/main.py
Normal file
@@ -0,0 +1,358 @@
|
||||
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 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,
|
||||
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("/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
|
||||
)
|
||||
13
d2warehouse/app/static/helpers.js
Normal file
13
d2warehouse/app/static/helpers.js
Normal 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;
|
||||
});
|
||||
}
|
||||
106
d2warehouse/app/static/style.css
Normal file
106
d2warehouse/app/static/style.css
Normal 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);
|
||||
}
|
||||
53
d2warehouse/app/templates/currency.html
Normal file
53
d2warehouse/app/templates/currency.html
Normal 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>
|
||||
|
||||
13
d2warehouse/app/templates/item.html
Normal file
13
d2warehouse/app/templates/item.html
Normal 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>
|
||||
28
d2warehouse/app/templates/list_stash.html
Normal file
28
d2warehouse/app/templates/list_stash.html
Normal 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>
|
||||
25
d2warehouse/app/templates/list_storage.html
Normal file
25
d2warehouse/app/templates/list_storage.html
Normal 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>
|
||||
6896
d2warehouse/data/affixes.json
Normal file
6896
d2warehouse/data/affixes.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
1251
d2warehouse/data/skills.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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": [
|
||||
|
||||
@@ -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
45
d2warehouse/db.py
Normal 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())
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
90
d2warehouse/schema.sql
Normal 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);
|
||||
@@ -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
|
||||
|
||||
55
d2warehouse/tests/test_db.py
Normal file
55
d2warehouse/tests/test_db.py
Normal 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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user