From fe7aed4c0a6aa09127674ae0036e75ea798817ff Mon Sep 17 00:00:00 2001 From: Andreas Date: Thu, 2 Nov 2023 20:53:33 +0100 Subject: [PATCH] Add writing of filter rules --- d2lootfilter/data.py | 21 +++++- d2lootfilter/filter.py | 20 +++-- d2lootfilter/format.py | 49 ++++++++++--- d2lootfilter/parser.py | 37 +++++----- d2lootfilter/writer.py | 163 +++++++++++++++++++++++++++++++++++++++-- example.filter | 76 +++++++++++++++++++ test.filter | 11 --- 7 files changed, 325 insertions(+), 52 deletions(-) create mode 100644 example.filter delete mode 100644 test.filter diff --git a/d2lootfilter/data.py b/d2lootfilter/data.py index 2818319..697d15a 100644 --- a/d2lootfilter/data.py +++ b/d2lootfilter/data.py @@ -6,16 +6,26 @@ from d2lootfilter.format import Class _item_names = [] _item_runes = [] +_item_assets = {} _class_map = {} +_path = None + def init(path: Path): - global _item_names, _item_runes + global _item_names, _item_runes, _path, _item_assets + _path = path 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) + with open(path / "hd" / "items" / "items.json", encoding="utf-8") as f: + dat = json.load(f) + for row in dat: + base = next(iter(row)) + asset = row[base]["asset"] + _item_assets[base] = asset for cls in Class: _class_map[cls] = set() @@ -39,6 +49,9 @@ def init(path: Path): cls = Class.Belt _class_map[cls].add(row["code"]) # TODO: weapon.txt, misc.txt -> class_map + for entry in _item_names: + if "Potion" in entry["enUS"]: + _class_map[Class.Potion].add(entry["Key"]) # Rune class for i in range(1, 34): @@ -55,3 +68,9 @@ def item_runes() -> list: def class_to_bases(cls: str) -> set: return _class_map[cls] + + +def item_asset(code: str) -> Path: + base: Path = _path / "hd" / "items" + file = next(base.glob(f"**/{_item_assets[code]}.json")) + return file diff --git a/d2lootfilter/filter.py b/d2lootfilter/filter.py index 2f4a57b..d34c605 100644 --- a/d2lootfilter/filter.py +++ b/d2lootfilter/filter.py @@ -1,4 +1,5 @@ from pathlib import Path +import sys from d2lootfilter import data from d2lootfilter.format import FilterRule from d2lootfilter.parser import parse_format @@ -9,21 +10,30 @@ def main(): # TODO: Parse args data_dir = Path("C:\\Program Files (x86)\\Diablo II Resurrected\\Data") + # Remove any previous rules + remover = FilterRemover(data_dir) + remover.remove_all_rules() + # Init data data.init(data_dir) # Parse filter for rules rules = [] - with open("test.filter") as f: + with open("example.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)) + rules.append(FilterRule(source_line=nline, verb=verb, props=prop)) - # Remove any previous rules - remover = FilterRemover(data_dir) - remover.remove_all_rules() + # Check for any base type duplicates + observed = {} + for rule in rules: + for base in rule.base_types: + if base in observed: + print(f"Error: base {base!r} appears in rule at line {observed[base]} and line {rule.source_line}") + sys.exit(1) + observed[base] = rule.source_line # Write new rules writer = FilterRuleWriter(data_dir) diff --git a/d2lootfilter/format.py b/d2lootfilter/format.py index 603e95e..bc5ff45 100644 --- a/d2lootfilter/format.py +++ b/d2lootfilter/format.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass, field from enum import Enum import re @@ -20,6 +21,7 @@ class Property(str, Enum): To = "To" # only for verb Rename AddVfx = "AddVfx" PlaySound = "PlaySound" + TextColor = "TextColor" class Class(str, Enum): @@ -33,6 +35,7 @@ class Class(str, Enum): Amulet = "Amulet" Belt = "Belt" Rune = "Rune" + Potion = "Potion" # TODO: Add missing classes @@ -42,18 +45,30 @@ class RelationalOp(str, Enum): class VisualEffect(str, Enum): Beam = "Beam" + Glitter = "Glitter" + Flash = "Flash" +class Color(str, Enum): + Red = "Red" + Blue = "Blue" + Purple = "Purple" + + +@dataclass 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: + verb: Verb + source_line: int + props: list[tuple[Property, str]] + base_types: list[str] | None = None + class_bases: list[str] | None = None + vfx: list[VisualEffect] = field(default_factory=list) + sounds: list[str] = field(default_factory=list) + rename: str | None = None + text_color: Color | None = None + + def __post_init__(self): + for prop in self.props: proptype, args = prop self.eval_prop(proptype, args) @@ -75,6 +90,8 @@ class FilterRule: self._add_vfx(args) case Property.PlaySound: self._play_sound(args) + case Property.TextColor: + self._text_color(args) case _: raise NotImplementedError() @@ -95,7 +112,7 @@ class FilterRule: found = True for runeitem in data.item_runes(): if ( - re.match(r"r[0-9][0-9]", runeitem["Key"]) + re.match(r"^r[0-9][0-9]$", runeitem["Key"]) and (not op and arg in runeitem["enUS"]) or (op and arg == runeitem["enUS"]) ): @@ -144,6 +161,18 @@ class FilterRule: def _play_sound(self, args): raise NotImplementedError() + def _text_color(self, args): + arg, rest = self._arg_next_literal(args, False) + try: + color = Color(arg) + except ValueError: + raise FormatError(f"Format error @ rule starting on line {self.source_line}: Invalid color {arg!r}.") + if len(rest) > 0: + raise FormatError( + f"Format error @ rule starting on line {self.source_line}: Trailing data after 'TextColor'" + ) + self.text_color = color + def _arg_str(self, args, optional=False): if len(args) > 0 and args[0] == '"': args = args[1:] diff --git a/d2lootfilter/parser.py b/d2lootfilter/parser.py index 3f8444b..b0801c2 100644 --- a/d2lootfilter/parser.py +++ b/d2lootfilter/parser.py @@ -14,18 +14,25 @@ def parse_format( properties = [] source_line = 0 for n, line in enumerate(lines): - if line.startswith("#"): - continue - if line.startswith(" ") or line.startswith("\t"): - indented = True + comment = line.startswith("#") + indented = line.startswith(" ") or line.startswith("\t") - if len(line) == 0: - indented = False - if verb is not None: - instructions.append((source_line, verb, properties)) - verb = None - properties = [] - elif not indented: + if (comment or not indented) and verb is not None: + instructions.append((source_line, verb, properties)) + verb = None + properties = [] + + if comment or len(line) == 0: + continue + + if indented: + splits = line.lstrip().split(" ", maxsplit=1) + 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])) + else: if verb: raise ParseError(f"Error @ line {n + 1}: Expected indentation") try: @@ -33,14 +40,6 @@ def parse_format( 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)) diff --git a/d2lootfilter/writer.py b/d2lootfilter/writer.py index 8cec0a7..5616b10 100644 --- a/d2lootfilter/writer.py +++ b/d2lootfilter/writer.py @@ -1,21 +1,172 @@ +from contextlib import contextmanager +from io import TextIOWrapper +from itertools import chain +import json +import os from pathlib import Path +import re +import shutil +from typing import Any, Iterator +from d2lootfilter.data import item_asset -from d2lootfilter.format import FilterRule +from d2lootfilter.format import Color, FilterRule, Verb, VisualEffect class FilterRuleWriter: def __init__(self, data_dir: Path): - pass + self.data_dir = data_dir def write(self, rule: FilterRule): - import pdb + match rule.verb: + case Verb.Rename: + self._rename(rule.base_types, rule.rename, rule.text_color) + case Verb.Hide: + self._rename(rule.base_types, "", rule.text_color) + case Verb.Show: + if rule.text_color: + self._rename(rule.base_types, "$BaseType$", rule.text_color) + case _: + raise NotImplementedError() + for vfx in rule.vfx: + self._vfx(rule.base_types, vfx) - pdb.set_trace() + def _rename(self, bases: list[str], name: str, color: Color | None): + names = None + runes = None + for base in bases: + if re.match(r"^r[0-9][0-9]$", base): + if runes is None: + runes = self._read_json(self.data_dir / "local" / "lng" / "strings" / "item-runes.json") + for entry in runes: + if entry["Key"] == base: + entry["enUS"] = self._color(color) + name.replace("$BaseType$", entry["enUS"]) + break + else: + if names is None: + names = self._read_json(self.data_dir / "local" / "lng" / "strings" / "item-names.json") + for entry in names: + if entry["Key"] == base: + entry["enUS"] = self._color(color) + name.replace("$BaseType$", entry["enUS"]) + break + if names: + self._write_json(self.data_dir / "local" / "lng" / "strings" / "item-names.json", names) + if runes: + self._write_json(self.data_dir / "local" / "lng" / "strings" / "item-runes.json", names) + + def _vfx(self, bases: list[str], vfx: VisualEffect): + for base in bases: + path = item_asset(base) + dat = self._read_json(path) + match vfx: + case VisualEffect.Beam: + dat["dependencies"]["particles"].append( + {"path": "data/hd/vfx/particles/overlays/object/horadric_light/fx_horadric_light.particles"} + ) + dat["entities"].append( + { + "type": "Entity", + "name": "entity_beam", + "id": 987654321001, + "components": [ + { + "type": "TransformDefinitionComponent", + "name": "component_transform1", + "position": {"x": 0, "y": 0, "z": 0}, + "orientation": {"x": 0, "y": 0, "z": 0, "w": 1}, + "scale": {"x": 1, "y": 1, "z": 1}, + "inheritOnlyPosition": False, + }, + { + "type": "VfxDefinitionComponent", + "name": "entity_vfx_beam", + "filename": "data/hd/vfx/particles/overlays/object/horadric_light/fx_horadric_light.particles", + "hardKillOnDestroy": False, + }, + ], + } + ) + case VisualEffect.Glitter: + dat["entities"].append( + { + "type": "Entity", + "name": "entity_glitter", + "id": 987654321002, + "components": [ + { + "type": "VfxDefinitionComponent", + "name": "entity_vfx_glitter", + "filename": "data/hd/vfx/particles/overlays/paladin/aura_fanatic/aura_fanatic.particles", + "hardKillOnDestroy": False, + } + ], + } + ) + case VisualEffect.Flash: + dat["entities"].append( + { + "type": "Entity", + "name": "entity_flash", + "id": 987654321003, + "components": [ + { + "type": "TransformDefinitionComponent", + "name": "component_transform1", + "position": {"x": 0, "y": 0, "z": 0}, + "orientation": {"x": 0, "y": 0, "z": 0, "w": 1}, + "scale": {"x": 1, "y": 1, "z": 1}, + "inheritOnlyPosition": False, + }, + { + "type": "VfxDefinitionComponent", + "name": "entity_vfx_flash", + "filename": "data/hd/vfx/particles/overlays/common/valkyriestart/valkriestart_overlay.particles", + "hardKillOnDestroy": False, + }, + ], + } + ) + case _: + raise RuntimeError(f"Unhandled vfx type {vfx}") + self._write_json(path, dat) + + @contextmanager + def _open_bk(self, path: Path) -> Iterator[TextIOWrapper]: + bk_path = path.parent / (path.name + ".d2lootfilter") + if not bk_path.exists(): + shutil.copy(path, bk_path) + f = open(path, "w", encoding="utf-8-sig") + yield f + f.close() + + def _write_json(self, path: Path, data: Any): + with self._open_bk(path) as f: + json.dump(data, f, ensure_ascii=False, indent=2) + print(f"Wrote new rules to {path.relative_to(self.data_dir)}") + + def _read_json(self, path: Path): + with open(path, encoding="utf-8-sig") as f: + return json.load(f) + + def _color(self, color: Color | None) -> str: + match color: + case Color.Red: + return "ÿc1" + case Color.Blue: + return "ÿc3" + case Color.Purple: + return "ÿc;" + case _: + pass + return "" class FilterRemover: def __init__(self, data_dir: Path): - pass + self.data_dir = data_dir def remove_all_rules(self): - pass + paths = chain(self.data_dir.glob("local/**/*.d2lootfilter"), self.data_dir.glob("hd/**/*.d2lootfilter")) + for path in paths: + dst_path = Path(path).with_suffix("") + os.replace(path, dst_path) + print(f"Removed filter rules in {dst_path.relative_to(self.data_dir)}") diff --git a/example.filter b/example.filter new file mode 100644 index 0000000..e821432 --- /dev/null +++ b/example.filter @@ -0,0 +1,76 @@ +Show + BaseType "Mal" "Ist" "Gul" "Vex" "Ohm" "Lo" "Sur" "Ber" "Jah" "Cham" "Zod" + Class "Rune" + AddVfx Beam + AddVfx Glitter + AddVfx Flash + +Hide + BaseType "Gas" "Thawing" "Antidote" "Fulminating" "Exploding" "Oil" "Stamina" + Class "Potion" + +Rename + BaseType "Small Charm" "Grand Charm" + To "** $BaseType$ **" + +Rename + BaseType == "Dusk Shroud" "Mage Plate" "Archon Plate" "Vortex Shield" "Sacred Targe" "Monarch" "Phase Blade" "Berserker Axe" + To "++ $BaseType$ ++" + +Rename + BaseType == "Thresher" "Cryptic Axe" "Colossus Voulge" "Giant Thresher" "Sacred Armor" "Lacquered Plate" "Hellforge Plate" "Kraken Shell" "Balrog Skin" "Boneweave" "Great Hauberk" "Loricated Mail" + To "[eth?] $BaseType$" + +# Healing Potions +Rename + BaseType == "Minor Healing Potion" + To "hp1" + TextColor Red +Rename + BaseType == "Light Healing Potion" + To "hp2" + TextColor Red +Rename + BaseType == "Healing Potion" + To "hp3" + TextColor Red +Rename + BaseType == "Greater Healing Potion" + To "hp4" + TextColor Red +Rename + BaseType == "Super Healing Potion" + To "HP5" + TextColor Red + +# Mana Potions +Rename + BaseType == "Minor Mana Potion" + To "mp1" + TextColor Blue +Rename + BaseType == "Light Mana Potion" + To "mp2" + TextColor Blue +Rename + BaseType == "Mana Potion" + To "mp3" + TextColor Blue +Rename + BaseType == "Greater Mana Potion" + To "mp4" + TextColor Blue +Rename + BaseType == "Super Mana Potion" + To "MP5" + TextColor Blue + +# Rejuvenation Potions +Rename + BaseType == "Rejuvenation Potion" + To "rp" + TextColor Purple +Rename + BaseType == "Full Rejuvenation Potion" + To "** FP **" + TextColor Purple diff --git a/test.filter b/test.filter deleted file mode 100644 index 82af1ec..0000000 --- a/test.filter +++ /dev/null @@ -1,11 +0,0 @@ -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$ **"