From 098c537e2f98edb424b0c67e13405c5e63f6fec8 Mon Sep 17 00:00:00 2001 From: Andreas Date: Thu, 2 Nov 2023 11:47:55 +0100 Subject: [PATCH] Initial Commit --- .gitignore | 6 ++ README.md | 2 + d2lootfilter/__init__.py | 0 d2lootfilter/data.py | 57 +++++++++++ d2lootfilter/filter.py | 31 ++++++ d2lootfilter/format.py | 199 +++++++++++++++++++++++++++++++++++++++ d2lootfilter/parser.py | 47 +++++++++ d2lootfilter/writer.py | 21 +++++ pyproject.toml | 30 ++++++ test.filter | 11 +++ 10 files changed, 404 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 d2lootfilter/__init__.py create mode 100644 d2lootfilter/data.py create mode 100644 d2lootfilter/filter.py create mode 100644 d2lootfilter/format.py create mode 100644 d2lootfilter/parser.py create mode 100644 d2lootfilter/writer.py create mode 100644 pyproject.toml create mode 100644 test.filter diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8f66d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +*.egg-info/ +/venv/ +/.coverage +/htmlcov diff --git a/README.md b/README.md new file mode 100644 index 0000000..1efd268 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# README +Work in Progress basic Diablo 2 lootfilter. diff --git a/d2lootfilter/__init__.py b/d2lootfilter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/d2lootfilter/data.py b/d2lootfilter/data.py new file mode 100644 index 0000000..2818319 --- /dev/null +++ b/d2lootfilter/data.py @@ -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] diff --git a/d2lootfilter/filter.py b/d2lootfilter/filter.py new file mode 100644 index 0000000..2f4a57b --- /dev/null +++ b/d2lootfilter/filter.py @@ -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) diff --git a/d2lootfilter/format.py b/d2lootfilter/format.py new file mode 100644 index 0000000..603e95e --- /dev/null +++ b/d2lootfilter/format.py @@ -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 :] diff --git a/d2lootfilter/parser.py b/d2lootfilter/parser.py new file mode 100644 index 0000000..3f8444b --- /dev/null +++ b/d2lootfilter/parser.py @@ -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 diff --git a/d2lootfilter/writer.py b/d2lootfilter/writer.py new file mode 100644 index 0000000..8cec0a7 --- /dev/null +++ b/d2lootfilter/writer.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dc5929b --- /dev/null +++ b/pyproject.toml @@ -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 diff --git a/test.filter b/test.filter new file mode 100644 index 0000000..82af1ec --- /dev/null +++ b/test.filter @@ -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$ **"