Add writing of filter rules
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
76
example.filter
Normal file
76
example.filter
Normal file
@@ -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
|
||||
11
test.filter
11
test.filter
@@ -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$ **"
|
||||
Reference in New Issue
Block a user