Add basic sqlite3 saving & loading support
This commit is contained in:
38
d2warehouse/db.py
Normal file
38
d2warehouse/db.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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 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/>.
|
# Mercator. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
STASH_TAB_MAGIC = b"\x55\xAA\x55\xAA"
|
STASH_TAB_MAGIC = b"\x55\xAA\x55\xAA"
|
||||||
|
STASH_TAB_VERSION = 99
|
||||||
ITEM_DATA_MAGIC = b"JM"
|
ITEM_DATA_MAGIC = b"JM"
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ from typing import Optional
|
|||||||
from bitarray import bitarray
|
from bitarray import bitarray
|
||||||
from bitarray.util import int2ba
|
from bitarray.util import int2ba
|
||||||
from dataclasses import dataclass
|
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")
|
_data_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
|
||||||
_basetype_map = None
|
_basetype_map = None
|
||||||
@@ -31,7 +33,7 @@ _set_item_map = None
|
|||||||
_runeword_map = None
|
_runeword_map = None
|
||||||
|
|
||||||
|
|
||||||
class Quality(Enum):
|
class Quality(IntEnum):
|
||||||
LOW = 1
|
LOW = 1
|
||||||
NORMAL = 2
|
NORMAL = 2
|
||||||
HIGH = 3
|
HIGH = 3
|
||||||
@@ -45,7 +47,7 @@ class Quality(Enum):
|
|||||||
return self.name.capitalize()
|
return self.name.capitalize()
|
||||||
|
|
||||||
|
|
||||||
class LowQualityType(Enum):
|
class LowQualityType(IntEnum):
|
||||||
CRUDE = 0
|
CRUDE = 0
|
||||||
CRACKED = 1
|
CRACKED = 1
|
||||||
DAMAGED = 2
|
DAMAGED = 2
|
||||||
@@ -60,7 +62,7 @@ class Stat:
|
|||||||
id: int | None = None # TODO: These 3 should probably not be optional
|
id: int | None = None # TODO: These 3 should probably not be optional
|
||||||
values: list[int] | None = None
|
values: list[int] | None = None
|
||||||
parameter: 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):
|
def print(self, indent=5):
|
||||||
print(" " * indent, str(self))
|
print(" " * indent, str(self))
|
||||||
@@ -88,6 +90,7 @@ def txtbits(bits: bitarray) -> str:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class Item:
|
class Item:
|
||||||
raw_data: bytes
|
raw_data: bytes
|
||||||
|
raw_version: int
|
||||||
is_identified: bool
|
is_identified: bool
|
||||||
is_socketed: bool
|
is_socketed: bool
|
||||||
is_beginner: bool
|
is_beginner: bool
|
||||||
@@ -201,6 +204,194 @@ class Item:
|
|||||||
base = lookup_basetype(self.code)
|
base = lookup_basetype(self.code)
|
||||||
return base["width"], base["height"]
|
return base["width"], base["height"]
|
||||||
|
|
||||||
|
def write_to_db(self, socketed_into=None, commit=True) -> int:
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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 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.sockets:
|
||||||
|
for socket in self.sockets:
|
||||||
|
socket.write_to_db(socketed_into=item_id, commit=False)
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return item_id
|
||||||
|
|
||||||
|
def load_from_db(id: int) -> "Item":
|
||||||
|
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 = ?""",
|
||||||
|
(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"],
|
||||||
|
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"])
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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:
|
def lookup_basetype(code: str) -> dict:
|
||||||
global _basetype_map
|
global _basetype_map
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from d2warehouse.item import (
|
|||||||
lookup_stat,
|
lookup_stat,
|
||||||
)
|
)
|
||||||
import d2warehouse.huffman as huffman
|
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):
|
class ParseError(RuntimeError):
|
||||||
@@ -82,7 +82,7 @@ def parse_stash_tab(data: bytes) -> tuple[bytes, StashTab]:
|
|||||||
|
|
||||||
if unknown != 1:
|
if unknown != 1:
|
||||||
ParseError("Unknown stash tab field is not 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)")
|
ParseError(f"Unsupported stash tab version ({version} instead of 99)")
|
||||||
|
|
||||||
tab = StashTab()
|
tab = StashTab()
|
||||||
@@ -133,6 +133,7 @@ def parse_item(data: bytes) -> tuple[bytes, Item]:
|
|||||||
simple_byte_sz = int((sockets_end + 7) / 8)
|
simple_byte_sz = int((sockets_end + 7) / 8)
|
||||||
item = Item(
|
item = Item(
|
||||||
data[:simple_byte_sz],
|
data[:simple_byte_sz],
|
||||||
|
STASH_TAB_VERSION,
|
||||||
is_identified,
|
is_identified,
|
||||||
is_socketed,
|
is_socketed,
|
||||||
is_beginner,
|
is_beginner,
|
||||||
|
|||||||
85
d2warehouse/schema.sql
Normal file
85
d2warehouse/schema.sql
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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,
|
||||||
|
|
||||||
|
-- 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;
|
||||||
|
-- * 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,
|
||||||
|
|
||||||
|
-- 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: list[Optional["Item"]] | None = None => 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 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_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)
|
||||||
|
);
|
||||||
31
d2warehouse/tests/test_db.py
Normal file
31
d2warehouse/tests/test_db.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from d2warehouse.db import close_db, create_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_id = item.write_to_db()
|
||||||
|
loaded_item = Item.load_from_db(db_id)
|
||||||
|
self.assertEqual(item, loaded_item)
|
||||||
@@ -44,7 +44,7 @@ d2test = "d2warehouse.test:main"
|
|||||||
version = {attr = "d2warehouse.__version__"}
|
version = {attr = "d2warehouse.__version__"}
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
d2warehouse = ["data/*.json"]
|
d2warehouse = ["data/*.json", "schema.sql"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = "--cov --cov-report html --cov-report term"
|
addopts = "--cov --cov-report html --cov-report term"
|
||||||
|
|||||||
Reference in New Issue
Block a user