Initial Commit
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
/venv/
|
||||
/.coverage
|
||||
/htmlcov
|
||||
2
README.md
Normal file
2
README.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# README
|
||||
Work in Progress basic Diablo 2 lootfilter.
|
||||
0
d2lootfilter/__init__.py
Normal file
0
d2lootfilter/__init__.py
Normal file
57
d2lootfilter/data.py
Normal file
57
d2lootfilter/data.py
Normal 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
31
d2lootfilter/filter.py
Normal 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
199
d2lootfilter/format.py
Normal 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
47
d2lootfilter/parser.py
Normal 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
21
d2lootfilter/writer.py
Normal 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
30
pyproject.toml
Normal 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
11
test.filter
Normal 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$ **"
|
||||
Reference in New Issue
Block a user