From ffac5edff57b8623ee655bb78333646316ce97ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Gim=C3=A9nez?= Date: Mon, 6 May 2024 10:15:37 +0900 Subject: [PATCH] refactor rule evaluator to make use of expr envs --- go.mod | 4 +- go.sum | 8 +- pkg/nip/file_reader.go | 2 +- pkg/nip/rule.go | 218 ++++++++++++++++++++++++----------------- pkg/nip/rule_test.go | 52 ++++++---- 5 files changed, 170 insertions(+), 114 deletions(-) diff --git a/go.mod b/go.mod index cbf2e22..216b764 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/expr-lang/expr v1.16.5 github.com/faiface/beep v1.1.0 github.com/stretchr/testify v1.9.0 - golang.org/x/sys v0.18.0 + golang.org/x/sys v0.20.0 ) require ( @@ -15,7 +15,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 // indirect - golang.org/x/image v0.15.0 // indirect + golang.org/x/image v0.16.0 // indirect golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index cd15a45..d2d1308 100644 --- a/go.sum +++ b/go.sum @@ -34,16 +34,16 @@ golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 h1:idBdZTd9UioThJp8KpM/rTSin golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= -golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= +golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw= +golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs= golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 h1:vyLBGJPIl9ZYbcQFM2USFmJBK6KI+t+z6jL0lbwjrnc= golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/nip/file_reader.go b/pkg/nip/file_reader.go index 22c0556..8c24e90 100644 --- a/pkg/nip/file_reader.go +++ b/pkg/nip/file_reader.go @@ -55,7 +55,7 @@ func ParseNIPFile(filePath string) (Rules, error) { for fileScanner.Scan() { lineNumber++ - rule, err := New(fileScanner.Text(), filePath, lineNumber) + rule, err := NewRule(fileScanner.Text(), filePath, lineNumber) if errors.Is(err, ErrEmptyRule) { continue } diff --git a/pkg/nip/rule.go b/pkg/nip/rule.go index 0943365..c49aee4 100644 --- a/pkg/nip/rule.go +++ b/pkg/nip/rule.go @@ -8,67 +8,42 @@ import ( "strings" "github.com/expr-lang/expr" + "github.com/expr-lang/expr/vm" "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/item" "github.com/hectorgimenez/d2go/pkg/data/stat" ) -var ( - fixedPropsRegexp = regexp.MustCompile(`(\[(type|quality|class|name|flag|color)]\s*(<=|<|>|>=|!=|==)\s*([a-zA-Z]+))`) - statsRegexp = regexp.MustCompile(`\[(.*?)]`) - maxQtyRegexp = regexp.MustCompile(`(\[maxquantity]\s*(<=|<|>|>=|!=|==)\s*([0-9]+))`) -) - -type Rule struct { - RawLine string // Original line, don't use it for evaluation - Stages [3]string - Filename string - LineNumber int - Enabled bool - maxQuantity int -} - -type Rules []Rule - -type RuleResult int - -var ( +const ( RuleResultFullMatch RuleResult = 1 RuleResultPartial RuleResult = 2 RuleResultNoMatch RuleResult = 3 ) -func (r Rules) EvaluateAll(it data.Item) (Rule, RuleResult) { - bestMatch := RuleResultNoMatch - bestMatchingRule := Rule{} - for _, rule := range r { - if rule.Enabled { - result, err := rule.Evaluate(it) - if err != nil { - continue - } - if result == RuleResultFullMatch { - return rule, result - } - if result == RuleResultPartial { - bestMatch = result - bestMatchingRule = rule - } - } - } +var ( + fixedPropsRegexp = regexp.MustCompile(`(\[(type|quality|class|name|flag|color)]\s*(<=|<|>|>=|!=|==)\s*([a-zA-Z0-9]+))`) + statsRegexp = regexp.MustCompile(`\[(.*?)]`) + maxQtyRegexp = regexp.MustCompile(`(\[maxquantity]\s*(<=|<|>|>=|!=|==)\s*([0-9]+))`) +) - return bestMatchingRule, bestMatch +type Rule struct { + RawLine string // Original line, don't use it for evaluation + Filename string + LineNumber int + Enabled bool + maxQuantity int + stage1 *vm.Program + stage2 *vm.Program + requiredStats []string } -func New(rawRule string, filename string, lineNumber int) (Rule, error) { - rule := sanitizeLine(rawRule) +type RuleResult int +type Rules []Rule - // Check for not supported stats - for _, prop := range statsRegexp.FindAllStringSubmatch(rule, -1) { - if slices.Contains(notSupportedStats, prop[1]) { - return Rule{}, fmt.Errorf("property %s is not supported, please remove it", prop[1]) - } - } +var fixedPropsList = map[string]int{"type": 0, "quality": 0, "class": 0, "name": 0, "flag": 0, "color": 0} + +func NewRule(rawRule string, filename string, lineNumber int) (Rule, error) { + rule := sanitizeLine(rawRule) // Try to get the maxquantity value and purge it from the rule, we can not evaluate it maxQuantity := 0 @@ -87,53 +62,84 @@ func New(rawRule string, filename string, lineNumber int) (Rule, error) { return Rule{}, ErrEmptyRule } - stages := [3]string{} - for i, stg := range strings.Split(rule, "#") { - stages[i] = strings.TrimSpace(stg) - } - - return Rule{ + r := Rule{ RawLine: rawRule, - Stages: stages, Filename: filename, LineNumber: lineNumber, Enabled: true, maxQuantity: maxQuantity, - }, nil + } + + for i, stg := range strings.Split(rule, "#") { + line := strings.TrimSpace(stg) + if line == "" { + break + } + + if i == 0 { + line, err := replaceStringPropertiesInStage1(line) + if err != nil { + return Rule{}, err + } + + line = strings.ReplaceAll(line, "[", "") + line = strings.ReplaceAll(line, "]", "") + program, err := expr.Compile(line, expr.Env(fixedPropsList)) + if err != nil { + return Rule{}, fmt.Errorf("error compiling rule: %w", err) + } + r.stage1 = program + } + + if i == 1 { + r.requiredStats = getRequiredStatsForRule(line) + + statsMap := make(map[string]int) + for _, prop := range r.requiredStats { + statsMap[prop] = 0 + // Check for not supported stats + if slices.Contains(notSupportedStats, prop) { + return Rule{}, fmt.Errorf("property %s is not supported, please remove it", prop) + } + } + + line = strings.ReplaceAll(line, "[", "") + line = strings.ReplaceAll(line, "]", "") + program, err := expr.Compile(line, expr.Env(statsMap)) + if err != nil { + return Rule{}, fmt.Errorf("error compiling rule: %w", err) + } + r.stage2 = program + + // We only care about first and second part, third one is maxquantity and was already parsed + break + } + } + + return r, nil } func (r Rule) Evaluate(it data.Item) (RuleResult, error) { - stage1 := r.Stages[0] - baseProperties := fixedPropsRegexp.FindAllStringSubmatch(stage1, -1) - for _, prop := range baseProperties { - switch prop[2] { + stage1Props := make(map[string]int) + for prop := range fixedPropsList { + switch prop { case "type": - partialReplace := strings.ReplaceAll(prop[0], fmt.Sprintf("[%s]", prop[2]), fmt.Sprintf("%d", typeAliases[it.TypeAsString()])) - partialReplace = strings.ReplaceAll(partialReplace, prop[4], fmt.Sprintf("%d", typeAliases[prop[4]])) - stage1 = strings.ReplaceAll(stage1, prop[0], partialReplace) + stage1Props["type"] = typeAliases[it.TypeAsString()] case "quality": - partialReplace := strings.ReplaceAll(prop[0], fmt.Sprintf("[%s]", prop[2]), fmt.Sprintf("%d", it.Quality)) - partialReplace = strings.ReplaceAll(partialReplace, prop[4], fmt.Sprintf("%d", qualityAliases[prop[4]])) - stage1 = strings.ReplaceAll(stage1, prop[0], partialReplace) + stage1Props["quality"] = int(it.Quality) case "class": - partialReplace := strings.ReplaceAll(prop[0], fmt.Sprintf("[%s]", prop[2]), fmt.Sprintf("%d", it.Desc().Tier())) - partialReplace = strings.ReplaceAll(partialReplace, prop[4], fmt.Sprintf("%d", classAliases[prop[4]])) - stage1 = strings.ReplaceAll(stage1, prop[0], partialReplace) + stage1Props["class"] = int(it.Desc().Tier()) case "name": - partialReplace := strings.ReplaceAll(prop[0], fmt.Sprintf("[%s]", prop[2]), fmt.Sprintf("%d", item.GetIDByName(string(it.Name)))) - partialReplace = strings.ReplaceAll(partialReplace, prop[4], fmt.Sprintf("%d", item.GetIDByName(prop[4]))) - stage1 = strings.ReplaceAll(stage1, prop[0], partialReplace) + stage1Props["name"] = it.ID case "flag": - partialReplace := strings.ReplaceAll(prop[0], fmt.Sprintf("[%s]", prop[2]), fmt.Sprintf("%d", map[bool]int{true: 1, false: 0}[it.Ethereal])) - partialReplace = strings.ReplaceAll(partialReplace, prop[4], fmt.Sprintf("%d", 1)) - stage1 = strings.ReplaceAll(stage1, prop[0], partialReplace) + stage1Props["flag"] = map[bool]int{true: 1, false: 0}[it.Ethereal] case "color": // TODO: Not supported yet } } // Let's evaluate first stage - stage1Result, err := expr.Eval(stage1, nil) + stage1Result, err := expr.Run(r.stage1, stage1Props) if err != nil { return RuleResultNoMatch, fmt.Errorf("error evaluating rule: %w", err) } @@ -143,27 +149,25 @@ func (r Rule) Evaluate(it data.Item) (RuleResult, error) { return RuleResultNoMatch, nil } - // Let's go with other stats now - // TODO: properties are missing (enhanceddefense, enhanceddamage, etc) - stage2 := r.Stages[1] stage2Result := true - if stage2 != "" { - for _, statName := range statsRegexp.FindAllStringSubmatch(stage2, -1) { - statData, found := statAliases[statName[1]] + if r.stage2 != nil { + stage2Props := make(map[string]int) + for _, statName := range r.requiredStats { + statData, found := statAliases[statName] if !found { - return RuleResultNoMatch, fmt.Errorf("property %s is not valid or not supported", statName[1]) + return RuleResultNoMatch, fmt.Errorf("property %s is not valid or not supported", statName) } // Exception for enhanceddefense, is not accurate - if statName[0] == "[enhanceddefense]" { + if strings.EqualFold(statName, "enhanceddefense") { enhancedDefense := r.calculateEnhancedDefense(it) - stage2 = strings.ReplaceAll(stage2, statName[0], fmt.Sprintf("%d", enhancedDefense)) + stage2Props[statName] = enhancedDefense continue } - if itemTypes, found := blockedStatsForItemType[statName[1]]; found { + if itemTypes, found := blockedStatsForItemType[statName]; found { if slices.Contains(itemTypes, it.TypeAsString()) { - return RuleResultNoMatch, fmt.Errorf("property %s is not supported for item type %s", statName[0], it.TypeAsString()) + return RuleResultNoMatch, fmt.Errorf("property %s is not supported for item type %s", statName, it.TypeAsString()) } } @@ -172,11 +176,10 @@ func (r Rule) Evaluate(it data.Item) (RuleResult, error) { layer = statData[1] } itemStat, _ := it.FindStat(stat.ID(statData[0]), layer) - // By default, value will be 0 is stat is not found, it's okay for evaluation purposes. - stage2 = strings.ReplaceAll(stage2, statName[0], fmt.Sprintf("%d", itemStat.Value)) + stage2Props[statName] = itemStat.Value } - res, err := expr.Eval(stage2, nil) + res, err := expr.Run(r.stage2, stage2Props) if err != nil { return RuleResultNoMatch, fmt.Errorf("error evaluating rule: %w", err) } @@ -192,10 +195,45 @@ func (r Rule) Evaluate(it data.Item) (RuleResult, error) { if stage1Result.(bool) && !it.Identified { return RuleResultPartial, nil } - return RuleResultNoMatch, nil } +func replaceStringPropertiesInStage1(stage1 string) (string, error) { + baseProperties := fixedPropsRegexp.FindAllStringSubmatch(stage1, -1) + for _, prop := range baseProperties { + replaceWith := "" + switch prop[2] { + case "type": + replaceWith = strings.ReplaceAll(prop[0], prop[4], fmt.Sprintf("%d", typeAliases[prop[4]])) + case "quality": + replaceWith = strings.ReplaceAll(prop[0], prop[4], fmt.Sprintf("%d", qualityAliases[prop[4]])) + case "class": + replaceWith = strings.ReplaceAll(prop[0], prop[4], fmt.Sprintf("%d", classAliases[prop[4]])) + case "name": + replaceWith = strings.ReplaceAll(prop[0], prop[4], fmt.Sprintf("%d", item.GetIDByName(prop[4]))) + case "flag": + replaceWith = strings.ReplaceAll(prop[0], prop[4], fmt.Sprintf("%d", 1)) + case "color": + // TODO: Not supported yet + return "", fmt.Errorf("property %s is not supported yet", prop[2]) + } + + if replaceWith != "" { + stage1 = strings.ReplaceAll(stage1, prop[0], replaceWith) + } + } + + return stage1, nil +} + +func getRequiredStatsForRule(line string) []string { + statsList := make([]string, 0) + for _, statName := range statsRegexp.FindAllStringSubmatch(line, -1) { + statsList = append(statsList, statName[1]) + } + return statsList +} + // MaxQuantity returns the maximum quantity of items that character can have, 0 means no limit func (r Rule) MaxQuantity() int { return r.maxQuantity diff --git a/pkg/nip/rule_test.go b/pkg/nip/rule_test.go index 175b133..f100867 100644 --- a/pkg/nip/rule_test.go +++ b/pkg/nip/rule_test.go @@ -36,6 +36,7 @@ func TestRule_Evaluate(t *testing.T) { }, args: args{ item: data.Item{ + ID: 603, Name: "SmAlLCharM", Quality: item.QualityMagic, Ethereal: true, @@ -57,6 +58,7 @@ func TestRule_Evaluate(t *testing.T) { }, args: args{ item: data.Item{ + ID: 373, Name: "mageplate", Quality: item.QualitySuperior, Ethereal: false, @@ -76,6 +78,7 @@ func TestRule_Evaluate(t *testing.T) { }, args: args{ item: data.Item{ + ID: 373, Name: "mageplate", Stats: []stat.Data{ {ID: stat.AddClassSkills, Value: 3, Layer: 1}, @@ -92,6 +95,7 @@ func TestRule_Evaluate(t *testing.T) { }, args: args{ item: data.Item{ + ID: 373, Name: "mageplate", Stats: []stat.Data{ {ID: stat.SingleSkill, Value: 3, Layer: 55}, @@ -100,20 +104,6 @@ func TestRule_Evaluate(t *testing.T) { }, want: RuleResultFullMatch, }, - { - name: "Ensure [color] returns error, not supported yet", - fields: fields{ - RawLine: "[type] == armor && [color] == 1000 && [quality] == magic", - Enabled: true, - }, - args: args{ - item: data.Item{ - Name: "mageplate", - Quality: item.QualityMagic, - }, - }, - wantErr: true, - }, { name: "Unid item matching base stats should return partial match", fields: fields{ @@ -122,6 +112,7 @@ func TestRule_Evaluate(t *testing.T) { }, args: args{ item: data.Item{ + ID: 373, Identified: false, Name: "mageplate", Quality: item.QualityMagic, @@ -137,6 +128,7 @@ func TestRule_Evaluate(t *testing.T) { }, args: args{ item: data.Item{ + ID: 373, Identified: false, Name: "mageplate", Quality: item.QualityMagic, @@ -174,7 +166,7 @@ func TestRule_Evaluate(t *testing.T) { Identified: true, ID: 0, Name: "handaxe", - Quality: item.QualityUnique, + Quality: item.QualitySuperior, Stats: []stat.Data{}, }, }, @@ -202,7 +194,7 @@ func TestRule_Evaluate(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - r, err := New(tt.fields.RawLine, tt.fields.Filename, tt.fields.LineNumber) + r, err := NewRule(tt.fields.RawLine, tt.fields.Filename, tt.fields.LineNumber) require.NoError(t, err) got, err := r.Evaluate(tt.args.item) if !tt.wantErr { @@ -234,10 +226,17 @@ func TestNew(t *testing.T) { }, wantErr: true, }, + { + name: "Ensure [color] returns error, not supported yet", + args: args{ + rawRule: "[type] == armor && [color] == 1000 && [quality] == magic", + }, + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := New(tt.args.rawRule, tt.args.filename, tt.args.lineNumber) + got, err := NewRule(tt.args.rawRule, tt.args.filename, tt.args.lineNumber) if tt.wantErr { require.Error(t, err) } else { @@ -247,3 +246,22 @@ func TestNew(t *testing.T) { }) } } + +func BenchmarkEvaluate(b *testing.B) { + it := data.Item{ + ID: 0, + Name: "Axe", + Quality: item.QualitySuperior, + } + + rule, err := NewRule( + "[type] == amulet && [quality] == crafted # ([shapeshiftingskilltab] >= 2 || [elementalskilltab] >= 2 || [druidsummoningskilltab] >= 2) && [fcr] >= 10 && ([strength]+[maxhp]+[maxmana] >= 60 || [dexterity]+[maxhp]+[maxmana] >= 60 || [strength]+[dexterity]+[maxhp] >= 50 || [strength]+[dexterity]+[maxmana] >= 55)", + "test", + 1, + ) + require.NoError(b, err) + + for n := 0; n < b.N; n++ { + rule.Evaluate(it) + } +}