Initial Commit

This commit is contained in:
2023-11-02 11:47:55 +01:00
commit 098c537e2f
10 changed files with 404 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
__pycache__/
*.pyc
*.egg-info/
/venv/
/.coverage
/htmlcov

2
README.md Normal file
View File

@@ -0,0 +1,2 @@
# README
Work in Progress basic Diablo 2 lootfilter.

0
d2lootfilter/__init__.py Normal file
View File

57
d2lootfilter/data.py Normal file
View File

@@ -0,0 +1,57 @@
from csv import DictReader
import json
from pathlib import Path
from d2lootfilter.format import Class
_item_names = []
_item_runes = []
_class_map = {}
def init(path: Path):
global _item_names, _item_runes
with open(path / "local" / "lng" / "strings" / "item-names.json", encoding="utf-8-sig") as f:
_item_names = json.load(f)
with open(path / "local" / "lng" / "strings" / "item-runes.json", encoding="utf-8-sig") as f:
_item_runes = json.load(f)
for cls in Class:
_class_map[cls] = set()
with open(path / "global" / "excel" / "armor.txt") as f:
for row in DictReader(f, delimiter="\t"):
# TODO: Proper class selection
n = row["name"]
cls = Class.Unknown
if "Cap" in n or "Helm" in n:
cls = Class.Helmet
elif "Armor" in n or "Mail" in n or "Plate" in n:
cls = Class.Chest
elif "Armor" in n or "Mail" in n or "Plate" in n:
cls = Class.Chest
elif "Gloves" in n:
cls = Class.Gloves
elif "Boots" in n:
cls = Class.Boots
elif "Belt" in n:
cls = Class.Belt
_class_map[cls].add(row["code"])
# TODO: weapon.txt, misc.txt -> class_map
# Rune class
for i in range(1, 34):
_class_map[Class.Rune].add(f"r{i:02d}")
def item_names() -> list:
return _item_names
def item_runes() -> list:
return _item_runes
def class_to_bases(cls: str) -> set:
return _class_map[cls]

31
d2lootfilter/filter.py Normal file
View File

@@ -0,0 +1,31 @@
from pathlib import Path
from d2lootfilter import data
from d2lootfilter.format import FilterRule
from d2lootfilter.parser import parse_format
from d2lootfilter.writer import FilterRemover, FilterRuleWriter
def main():
# TODO: Parse args
data_dir = Path("C:\\Program Files (x86)\\Diablo II Resurrected\\Data")
# Init data
data.init(data_dir)
# Parse filter for rules
rules = []
with open("test.filter") as f:
lines = f.read().splitlines()
instructions = parse_format(lines)
for instr in instructions:
nline, verb, prop = instr
rules.append(FilterRule(nline, verb, prop))
# Remove any previous rules
remover = FilterRemover(data_dir)
remover.remove_all_rules()
# Write new rules
writer = FilterRuleWriter(data_dir)
for rule in rules:
writer.write(rule)

199
d2lootfilter/format.py Normal file
View File

@@ -0,0 +1,199 @@
from enum import Enum
import re
from d2lootfilter import data
class FormatError(Exception):
pass
class Verb(str, Enum):
Show = "Show"
Hide = "Hide"
Rename = "Rename"
class Property(str, Enum):
BaseType = "BaseType"
Class = "Class"
To = "To" # only for verb Rename
AddVfx = "AddVfx"
PlaySound = "PlaySound"
class Class(str, Enum):
Unknown = "N/A"
Helmet = "Helmet"
Chest = "Chest"
Gloves = "Gloves"
Shield = "Shield"
Boots = "Boots"
Ring = "Ring"
Amulet = "Amulet"
Belt = "Belt"
Rune = "Rune"
# TODO: Add missing classes
class RelationalOp(str, Enum):
Equal = "=="
class VisualEffect(str, Enum):
Beam = "Beam"
class FilterRule:
def __init__(self, source_line: int, verb: Verb, props: list[tuple[Property, str]]):
self.source_line = source_line
self.base_types = None
self.class_bases = None
self.rename = None
self.vfx = []
self.sounds = []
self.verb = verb
for prop in props:
proptype, args = prop
self.eval_prop(proptype, args)
if self.class_bases:
if self.base_types:
self.base_types = [b for b in self.base_types if b in self.class_bases]
else:
self.base_types = self.class_bases
def eval_prop(self, prop: Property, args: str):
match prop:
case Property.BaseType:
self._base_type(args)
case Property.Class:
self._class(args)
case Property.To:
self._to(args)
case Property.AddVfx:
self._add_vfx(args)
case Property.PlaySound:
self._play_sound(args)
case _:
raise NotImplementedError()
def _base_type(self, args):
if not self.base_types:
self.base_types = []
op, args = self._arg_relational_op(args, optional=True)
if op not in [None, RelationalOp.Equal]:
raise FormatError(f"Format error @ rule starting on line {self.source_line}: Invalid BaseType op")
arg, args = self._arg_str(args)
while arg:
found = False
for item in data.item_names():
if (not op and arg in item["enUS"]) or (op and arg == item["enUS"]):
self.base_types.append(item["Key"])
found = True
for runeitem in data.item_runes():
if (
re.match(r"r[0-9][0-9]", runeitem["Key"])
and (not op and arg in runeitem["enUS"])
or (op and arg == runeitem["enUS"])
):
self.base_types.append(runeitem["Key"])
found = True
if not found:
raise FormatError(
f"Format error @ rule starting on line {self.source_line}: No basetype matching {arg!r}"
)
arg, args = self._arg_str(args, optional=True)
def _class(self, args):
if not self.class_bases:
self.class_bases = []
arg, args = self._arg_str(args)
while arg:
try:
cls = Class(arg)
except ValueError:
raise FormatError(f"Format error @ rule starting on line {self.source_line}: Invalid class {arg!r}")
bases = data.class_to_bases(cls)
self.class_bases.extend(bases)
arg, args = self._arg_str(args, optional=True)
def _to(self, args):
if self.verb != Verb.Rename:
raise FormatError(
f"Format error @ rule starting on line {self.source_line}: Property 'To' can only appear in verb 'Rename'."
)
arg, rest = self._arg_str(args)
if len(rest) > 0:
raise FormatError(f"Format error @ rule starting on line {self.source_line}: Trailing data after 'To'")
self.rename = arg
def _add_vfx(self, args):
arg, rest = self._arg_str(args, False)
try:
vfx = VisualEffect(arg)
except ValueError:
raise FormatError(f"Format error @ rule starting on line {self.source_line}: Invalid vfx {arg!r}.")
if len(rest) > 0:
raise FormatError(f"Format error @ rule starting on line {self.source_line}: Trailing data after 'AddVfx'")
self.vfx.append(vfx)
def _play_sound(self, args):
raise NotImplementedError()
def _arg_str(self, args, optional=False):
if len(args) > 0 and args[0] == '"':
args = args[1:]
try:
end = args.index('"')
if len(args) > end + 1 and args[end + 1] != " ":
raise FormatError(
f"Format error @ rule starting on line {self.source_line}: Expected space after quote"
)
arg, rest = args[:end], args[end + 2 :]
except ValueError:
raise FormatError(
f"Format error @ rule starting on line {self.source_line}: Missing quote to close string"
)
else:
arg, rest = self._arg_next_literal(args, optional)
arg = arg.strip()
if len(arg) == 0:
if not optional:
raise FormatError(
f"Format error @ rule starting on line {self.source_line}: Missing quote to close string"
)
arg = None
return arg, rest
def _arg_int(self, args):
arg, args = self._arg_next_literal(args, False)
try:
val = int(arg)
except ValueError:
raise FormatError(f"Format error @ rule starting on line {self.source_line}: Expected integer")
return val, args
def _arg_relational_op(self, args, optional=False):
text, rest = self._arg_next_literal(args, optional=optional)
try:
op = RelationalOp(text)
except ValueError:
if not optional:
FormatError(f"Format error @ rule starting on line {self.source_line}: Missing Relational Operator")
return None, args
return op, rest
def _arg_next_literal(self, args, optional):
try:
end = args.index(" ")
except ValueError:
end = len(args)
val = args[:end].strip()
if not optional and len(val) == 0:
raise FormatError(f"Format error @ rule starting on line {self.source_line}: Empty literal")
return val, args[end + 1 :]

47
d2lootfilter/parser.py Normal file
View File

@@ -0,0 +1,47 @@
from d2lootfilter.format import Property, Verb
class ParseError(Exception):
pass
def parse_format(
lines: list[str],
) -> list[tuple[int, Verb, list[tuple[Property, str]]]]:
instructions = []
verb = None
indented = False
properties = []
source_line = 0
for n, line in enumerate(lines):
if line.startswith("#"):
continue
if line.startswith(" ") or line.startswith("\t"):
indented = True
if len(line) == 0:
indented = False
if verb is not None:
instructions.append((source_line, verb, properties))
verb = None
properties = []
elif not indented:
if verb:
raise ParseError(f"Error @ line {n + 1}: Expected indentation")
try:
verb = Verb(line)
source_line = n + 1
except ValueError:
raise ParseError(f"Error @ line {n + 1}: Invalid verb {line!r}")
else:
splits = line.lstrip().split(" ", maxsplit=1)
print(splits)
try:
prop = Property(splits[0])
except ValueError:
raise ParseError(f"Error @ line {n + 1}: Invalid property {splits[0]!r}")
properties.append((prop, "" if len(splits) == 0 else splits[1]))
if verb is not None:
instructions.append((source_line, verb, properties))
return instructions

21
d2lootfilter/writer.py Normal file
View File

@@ -0,0 +1,21 @@
from pathlib import Path
from d2lootfilter.format import FilterRule
class FilterRuleWriter:
def __init__(self, data_dir: Path):
pass
def write(self, rule: FilterRule):
import pdb
pdb.set_trace()
class FilterRemover:
def __init__(self, data_dir: Path):
pass
def remove_all_rules(self):
pass

30
pyproject.toml Normal file
View File

@@ -0,0 +1,30 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "d2lootfilter"
version = "0.1.0"
authors = [
{name = "Andreas", email="andreasruden91@gmail.com"},
]
description = "basic loot filter for d2 inspired by PoE"
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Console",
]
requires-python = ">=3.10"
license = {text = "GPLv3 License"}
dependencies = []
[project.optional-dependencies]
dev = [
"black",
]
[project.scripts]
d2filter = "d2lootfilter.filter:main"
[tool.black]
line-length = 120

11
test.filter Normal file
View File

@@ -0,0 +1,11 @@
Show
BaseType "Mal" "Ist" "Gul" "Vex" "Ohm" "Lo" "Sur" "Ber" "Jah" "Cham" "Zod"
Class "Rune"
AddVfx Beam
Hide
BaseType == "Key"
Rename
BaseType "Charm"
To "** $BaseType$ **"