Files
d2go/pkg/nip/rule.go

502 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package nip
import (
"fmt"
"regexp"
"strconv"
"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"
)
const (
RuleResultFullMatch RuleResult = 1
RuleResultPartial RuleResult = 2
RuleResultNoMatch RuleResult = 3
)
var (
fixedPropsRegexp = regexp.MustCompile(`(\[(type|quality|class|name|flag|color|prefix|suffix)]\s*(<=|<|>|>=|!=|==)\s*([a-zA-Z0-9]+))`)
statsRegexp = regexp.MustCompile(`\[(.*?)]`)
maxQtyRegexp = regexp.MustCompile(`(\[maxquantity]\s*(<=|<|>|>=|!=|==)\s*([0-9]+))`)
tierRegexp = regexp.MustCompile(`(\[tier]\s*(<=|<|>|>=|!=|==)\s*([0-9]+))`)
mercTierRegexp = regexp.MustCompile(`(\[merctier]\s*(<=|<|>|>=|!=|==)\s*([0-9]+))`)
)
type Rule struct {
RawLine string // Original line, don't use it for evaluation
Filename string
LineNumber int
Enabled bool
maxQuantity int
tier float64
mercTier float64
stage1 *vm.Program
stage2 *vm.Program
requiredStats []string
}
type RuleResult int
type Rules []Rule
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
}
}
}
return bestMatchingRule, bestMatch
}
func (r Rules) EvaluateAllIgnoreTiers(it data.Item) (Rule, RuleResult) {
bestMatch := RuleResultNoMatch
bestMatchingRule := Rule{}
for _, rule := range r {
if rule.Enabled {
if rule.tier > 0 || rule.mercTier > 0 {
continue
}
result, err := rule.Evaluate(it)
if err != nil {
continue
}
if result == RuleResultFullMatch {
return rule, result
}
if result == RuleResultPartial {
bestMatch = result
bestMatchingRule = rule
}
}
}
return bestMatchingRule, bestMatch
}
func (r Rules) EvaluateTiers(it data.Item, tierRulesIndexes []int) (Rule, Rule) {
highestTierRule := Rule{}
highestMercTierRule := Rule{}
for _, ruleIndex := range tierRulesIndexes {
if ruleIndex < len(r) {
rule := r[ruleIndex]
if rule.Enabled {
result, err := rule.Evaluate(it)
if err != nil {
continue
}
if result == RuleResultFullMatch || result == RuleResultPartial {
if rule.tier > highestTierRule.tier {
highestTierRule = rule
}
if rule.mercTier > highestMercTierRule.mercTier {
highestMercTierRule = rule
}
}
}
}
}
return highestTierRule, highestMercTierRule
}
var fixedPropsList = map[string]int{"type": 0, "quality": 0, "class": 0, "name": 0, "flag": 0, "color": 0, "prefix": 0, "suffix": 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
for _, prop := range maxQtyRegexp.FindAllStringSubmatch(rule, -1) {
mxQty, err := strconv.Atoi(prop[3])
if err != nil {
return Rule{}, fmt.Errorf("error parsing maxquantity value %s: %w", prop[3], err)
}
maxQuantity = mxQty
rule = strings.ReplaceAll(rule, prop[0], "")
}
// Try to get the tier value and purge it from the rule, we can not evaluate it yet
tier := 0.0
for _, prop := range tierRegexp.FindAllStringSubmatch(rule, -1) {
parsedTier, err := strconv.Atoi(prop[3])
if err != nil {
return Rule{}, fmt.Errorf("error parsing tier value %s: %w", prop[3], err)
}
tier = float64(parsedTier)
rule = strings.ReplaceAll(rule, prop[0], "")
}
// Try to get the merctier value and purge it from the rule, we can not evaluate it yet
mercTier := 0.0
for _, prop := range mercTierRegexp.FindAllStringSubmatch(rule, -1) {
parsedMercTier, err := strconv.Atoi(prop[3])
if err != nil {
return Rule{}, fmt.Errorf("error parsing merctier value %s: %w", prop[3], err)
}
mercTier = float64(parsedMercTier)
rule = strings.ReplaceAll(rule, prop[0], "")
}
// Sanitize again, just in case we messed up the rule while parsing maxquantity
rule = sanitizeLine(rule)
if rule == "" {
return Rule{}, ErrEmptyRule
}
r := Rule{
RawLine: rawRule,
Filename: filename,
LineNumber: lineNumber,
Enabled: true,
maxQuantity: maxQuantity,
tier: tier,
mercTier: mercTier,
}
parts := strings.Split(rule, "#")
if len(parts) > 0 {
stage1 := strings.TrimSpace(parts[0])
if stage1 != "" {
line, err := replaceStringPropertiesInStage1(stage1)
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 stage1: %w", err)
}
r.stage1 = program
}
}
if len(parts) > 1 {
stage2 := strings.TrimSpace(parts[1])
if stage2 != "" {
// Extract stats before removing brackets for compilation
r.requiredStats = getRequiredStatsForRule(stage2)
statsMap := make(map[string]int)
for _, prop := range r.requiredStats {
statsMap[prop] = 0
}
// Normalize whitespace around operators in parenthesized expressions
stage2 = normalizeParenthesizedExpressions(stage2)
// Remove brackets for compilation
compileReady := strings.ReplaceAll(stage2, "[", "")
compileReady = strings.ReplaceAll(compileReady, "]", "")
program, err := expr.Compile(compileReady, expr.Env(statsMap))
if err != nil {
return Rule{}, fmt.Errorf("error compiling rule stage2: %w, expression: %s", err, compileReady)
}
r.stage2 = program
}
}
return r, nil
}
func normalizeParenthesizedExpressions(expr string) string {
// Normalize common operators
expr = strings.ReplaceAll(expr, "||", " || ")
expr = strings.ReplaceAll(expr, "&&", " && ")
expr = strings.ReplaceAll(expr, "==", " == ")
expr = strings.ReplaceAll(expr, "!=", " != ")
expr = strings.ReplaceAll(expr, ">=", " >= ")
expr = strings.ReplaceAll(expr, "<=", " <= ")
// Fix extra spaces
expr = regexp.MustCompile(`\s+`).ReplaceAllString(expr, " ")
// Normalize parentheses spacing
expr = strings.ReplaceAll(expr, "( ", "(")
expr = strings.ReplaceAll(expr, " )", ")")
return expr
}
func (r Rule) Evaluate(it data.Item) (RuleResult, error) {
// Stage 1: Basic properties evaluation
stage1Props := make(map[string]int)
for prop := range fixedPropsList {
switch prop {
case "type":
stage1Props["type"] = it.Type().ID
case "quality":
stage1Props["quality"] = int(it.Quality)
case "class":
stage1Props["class"] = int(it.Desc().Tier())
case "name":
stage1Props["name"] = it.ID
case "flag":
stage1Props["flag"] = map[bool]int{true: 1, false: 0}[it.Ethereal]
case "prefix":
if it.Affixes.Rare.Prefix != 0 {
stage1Props["prefix"] = int(it.Affixes.Rare.Prefix)
}
for _, prefix := range it.Affixes.Magic.Prefixes {
if prefix != 0 {
stage1Props["prefix"] = int(prefix)
break
}
}
case "suffix":
if it.Affixes.Rare.Suffix != 0 {
stage1Props["suffix"] = int(it.Affixes.Rare.Suffix)
}
for _, suffix := range it.Affixes.Magic.Suffixes {
if suffix != 0 {
stage1Props["suffix"] = int(suffix)
break
}
}
case "color":
// TODO: Not supported yet
}
}
// Check if stage1 exists before evaluating
if r.stage1 == nil {
return RuleResultNoMatch, fmt.Errorf("stage1 program is nil")
}
// Let's evaluate first stage
stage1Result, err := expr.Run(r.stage1, stage1Props)
if err != nil {
return RuleResultNoMatch, fmt.Errorf("error evaluating rule stage1: %w", err)
}
// If stage1 does not match, we can stop here, nothing else to match
if !stage1Result.(bool) {
return RuleResultNoMatch, nil
}
// If we have no stage2 (no stat requirements), allow full match even for unidentified items
if r.stage2 == nil {
return RuleResultFullMatch, nil
}
// From here on we have stat requirements - return partial match for unidentified items
if !it.Identified {
return RuleResultPartial, nil
}
stage2Props := make(map[string]int)
stage2 := ""
if len(strings.Split(r.RawLine, "#")) > 1 {
stage2 = strings.ToLower(strings.Split(r.RawLine, "#")[1])
}
// Special handling for skill tabs
if strings.Contains(stage2, "[itemaddskilltab]") {
stage2Props["itemaddskilltab"] = evaluateSkillTabSum(it)
}
// Special handling for class skills
if strings.Contains(stage2, "[itemaddclassskills]") {
stage2Props["itemaddclassskills"] = evaluateClassSkillsSum(it)
}
// Preprocess stage2 to see if certain stats are being compared to zero
zeroCheckStats := make(map[string]bool)
for _, statName := range r.requiredStats {
if strings.Contains(stage2, "["+statName+"] == 0") ||
strings.Contains(stage2, "["+statName+"]==0") {
zeroCheckStats[statName] = true
}
}
// Handle resist sums
hasAnyResist := false
// Detect if this is a rule with a resist sum expression
// We need to check both for direct addition/subtraction and parenthesized expressions
isResistSum := false
if strings.Contains(stage2, "resist") {
isResistSum = strings.Contains(stage2, "+") || strings.Contains(stage2, "-") ||
(strings.Contains(stage2, "(") && strings.Contains(stage2, ")"))
}
if isResistSum {
// Check if the item has any resist stats at all
for _, statName := range r.requiredStats {
if !strings.Contains(statName, "resist") {
continue
}
statData, found := statAliases[statName]
if !found {
continue
}
layer := 0
if len(statData) > 1 {
layer = statData[1]
}
if itemStat, found := it.FindStat(stat.ID(statData[0]), layer); found && itemStat.Value != 0 {
hasAnyResist = true
break
}
}
}
// Evaluate each required stat
for _, statName := range r.requiredStats {
// Skip stats we've already handled
if statName == "itemaddskilltab" || statName == "itemaddclassskills" {
continue
}
statData, found := statAliases[statName]
if !found {
return RuleResultNoMatch, fmt.Errorf("property %s is not valid or not supported", statName)
}
layer := 0
if len(statData) > 1 {
layer = statData[1]
}
// Use the FindStat method which handles both Stats and BaseStats
statFound := false
var statValue int
if itemStat, found := it.FindStat(stat.ID(statData[0]), layer); found {
statValue = itemStat.Value
statFound = true
}
// Special handling for stats not found
if !statFound {
isResistStat := strings.Contains(statName, "resist")
// When the rule contains a resist-sum but the item has no resists,
// dont return NoMatch—set the stat to 0 so the sum evaluates to false,
// and let other OR conditions decide the result.
if isResistStat && isResistSum && !hasAnyResist {
stage2Props[statName] = 0
continue
}
// For all other missing stats, default to 0
stage2Props[statName] = 0
} else {
stage2Props[statName] = statValue
}
}
res, err := expr.Run(r.stage2, stage2Props)
if err != nil {
return RuleResultNoMatch, fmt.Errorf("error evaluating rule stage2: %w", err)
}
// 100% rule match, we can return here
if res.(bool) {
return RuleResultFullMatch, 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", item.ItemTypes[typeAliases[prop[4]]].ID))
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 "prefix", "suffix":
// Handle prefix/suffix IDs
replaceWith = strings.ReplaceAll(prop[0], prop[4], prop[4])
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)
statsFound := make(map[string]bool)
for _, statName := range statsRegexp.FindAllStringSubmatch(line, -1) {
if !statsFound[statName[1]] {
statsList = append(statsList, statName[1])
statsFound[statName[1]] = true
}
}
return statsList
}
func evaluateClassSkillsSum(it data.Item) int {
// Check all class skills stats
totalClassSkills := 0
maxLayer := 6 // in aliases.go the max layer for class skills is 6 (itemaddassassinskills)
for layer := 0; layer <= maxLayer; layer++ {
if itemStat, found := it.FindStat(stat.AddClassSkills, layer); found && itemStat.Value > 0 {
totalClassSkills += itemStat.Value
}
}
return totalClassSkills
}
func evaluateSkillTabSum(it data.Item) int {
// Check all skill tab stats
totalSkillTabs := 0
maxLayer := 50 // in aliases.go the max layer for skill tabs is 50 (itemaddmartialartsskilltab)
for layer := 0; layer <= maxLayer; layer++ {
if itemStat, found := it.FindStat(stat.AddSkillTab, layer); found && itemStat.Value > 0 {
totalSkillTabs += itemStat.Value
}
}
return totalSkillTabs
}
// MaxQuantity returns the maximum quantity of items that character can have, 0 means no limit
func (r Rule) MaxQuantity() int {
return r.maxQuantity
}
func (r Rule) Tier() float64 {
return r.tier
}
func (r Rule) MercTier() float64 {
return r.mercTier
}