refactor rule evaluator to make use of expr envs

This commit is contained in:
Héctor Giménez
2024-05-06 10:15:37 +09:00
parent 8315e55594
commit ffac5edff5
5 changed files with 170 additions and 114 deletions

4
go.mod
View File

@@ -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
)

8
go.sum
View File

@@ -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=

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)
}
}