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]+))`) whitespaceRegexp = regexp.MustCompile(`\s+`) ) 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 skillTabSum bool classSkillSum bool resistSum bool nonResistStat bool } 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} // flagBit* are the bits Evaluate sets on the runtime `flag` value AND the // rewriter (replaceStringPropertiesInStage1) recognises. flagAllBits is the // disjunction of every supported flag bit: the rewriter enumerates all subsets // of flagAllBits when expanding bit tests for the `==`/`!=` operators (expr-lang // has no bitwise operators). To add a new flag bit, update Evaluate to set it, // add a case in the rewriter's name->bit switch, AND fold it into flagAllBits // so the enumeration sees it. const ( flagBitEthereal = 0x400000 flagBitRuneword = 0x4000000 flagAllBits = flagBitEthereal | flagBitRuneword ) var ( maxClassSkillsLayer = maxAliasLayer(stat.AddClassSkills) maxSkillTabLayer = maxAliasLayer(stat.AddSkillTab) ) 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.SplitN(rule, "#", 3) 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) stage2Source := "" rawParts := strings.SplitN(rawRule, "#", 3) if len(rawParts) > 1 { stage2Source = strings.ToLower(rawParts[1]) } r.skillTabSum = strings.Contains(stage2Source, "[itemaddskilltab]") r.classSkillSum = strings.Contains(stage2Source, "[itemaddclassskills]") if strings.Contains(stage2Source, "resist") { r.resistSum = strings.Contains(stage2Source, "+") || strings.Contains(stage2Source, "-") || (strings.Contains(stage2Source, "(") && strings.Contains(stage2Source, ")")) } statsMap := make(map[string]int) for _, prop := range r.requiredStats { statsMap[prop] = 0 if !strings.Contains(prop, "resist") { r.nonResistStat = true } } // 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 = whitespaceRegexp.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 desc := it.Desc() /* // 0x400000 (eth) | 0x4000000 (runeword) kolbot style. Bit values are // shared with the rewriter (replaceStringPropertiesInStage1) via the // package-level flagBit* constants so the rewriter's enumeration of // possible flag-states stays in lock-step with the values written // here — adding a third bit requires editing both this writer AND // the rewriter, but the constants make the dependency discoverable. */ currentFlag := 0 if it.Ethereal { currentFlag |= flagBitEthereal } if it.IsRuneword { currentFlag |= flagBitRuneword } stage1Props := map[string]int{ "type": desc.GetType().ID, "quality": int(it.Quality), "class": int(desc.Tier()), "name": it.ID, "flag": currentFlag, } 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 } } 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 } } // 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, len(r.requiredStats)) // Special handling for skill tabs if r.skillTabSum { stage2Props["itemaddskilltab"] = evaluateSkillTabSum(it) } // Special handling for class skills if r.classSkillSum { stage2Props["itemaddclassskills"] = evaluateClassSkillsSum(it) } // Handle resist sums - check if item has any resist stats (including negative values) hasAnyResist := false if r.resistSum { // Check if the item has any resist stats at all (including negative values like sunders) 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] } // Check if stat exists at all, regardless of value (including negative) if _, found := it.FindStat(stat.ID(statData[0]), layer); found { 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 isChanceToCast := false targetID := stat.ID(statData[0]) // Special cases, those encode skill ID and level in the layer switch targetID { case stat.SkillOnAttack, stat.SkillOnKill, stat.SkillOnDeath, stat.SkillOnHit, stat.SkillOnLevelUp, stat.SkillOnGetHit, stat.ItemChargedSkill: isChanceToCast = true } if isChanceToCast && (layer == 1 || layer == 2) { var foundStat *stat.Data for _, s := range it.Stats { if s.ID == targetID { foundStat = &s break } } if foundStat == nil { for _, s := range it.BaseStats { if s.ID == targetID { foundStat = &s break } } } if foundStat != nil { statFound = true skillID := foundStat.Layer >> 6 level := foundStat.Layer & 0x3F if layer == 1 { statValue = skillID } else { statValue = level } } } else { if itemStat, found := it.FindStat(stat.ID(statData[0]), layer); found { statValue = itemStat.Value statFound = true } } // Special handling for stats not found if !statFound { if statName == "allres" { stage2Props[statName] = evaluateAllRes(it) continue } isResistStat := strings.Contains(statName, "resist") // Special case: Pure resist-sum rules (like sunders) should fail early if no resists exist // But mixed rules (resists OR other stats) should continue to check other conditions if isResistStat && r.resistSum && !hasAnyResist && !r.nonResistStat { // This is a pure resist rule and item has no resists - can't match return RuleResultNoMatch, nil } // For all other cases, default missing stats to 0 and let expression evaluate 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": // [flag] is a bitmask at runtime (ethereal | runeword), so equality must be // a bit test, not a scalar compare. Previously "[flag] == ethereal" rewrote // to "flag == 0x400000", which evaluated FALSE on an ethereal runeword (whose // flag is 0x4400000 == eth|rw). expr-lang has no bitwise operators, so we // enumerate the two states the chosen bit can produce. flagAllBits below // MUST stay in sync with the writer in Evaluate — every bit Evaluate can // set must appear here, otherwise the negation form may miss valid items. name := strings.ToLower(prop[4]) var bit int switch name { case "runeword": bit = flagBitRuneword case "ethereal": bit = flagBitEthereal default: bit = 1 } switch { case bit == 1: // Unknown flag name — preserve legacy behaviour (replace value with 1). replaceWith = strings.ReplaceAll(prop[0], prop[4], "1") case prop[3] == "==": switch bit { case flagBitRuneword: replaceWith = fmt.Sprintf("(flag == %d || flag == %d)", flagBitRuneword, flagBitRuneword|flagBitEthereal) case flagBitEthereal: replaceWith = fmt.Sprintf("(flag == %d || flag == %d)", flagBitEthereal, flagBitRuneword|flagBitEthereal) } case prop[3] == "!=": switch bit { case flagBitRuneword: replaceWith = fmt.Sprintf("(flag == 0 || flag == %d)", flagBitEthereal) case flagBitEthereal: replaceWith = fmt.Sprintf("(flag == 0 || flag == %d)", flagBitRuneword) } default: // Comparison operators other than ==/!= are nonsensical for a bitmask // but kept as scalar compares so exotic legacy rules don't suddenly fail. replaceWith = strings.ReplaceAll(prop[0], prop[4], fmt.Sprintf("%d", bit)) } 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 } // ValidateStats checks that all required stats in stage2 are valid aliases. func (r Rule) ValidateStats() error { for _, statName := range r.requiredStats { if _, found := statAliases[statName]; !found { return fmt.Errorf("property %s is not valid or not supported", statName) } } return nil } func evaluateClassSkillsSum(it data.Item) int { return evaluatePositiveStatSum(it, stat.AddClassSkills, maxClassSkillsLayer) } func evaluateSkillTabSum(it data.Item) int { return evaluatePositiveStatSum(it, stat.AddSkillTab, maxSkillTabLayer) } func evaluateAllRes(it data.Item) int { resistStats := []stat.ID{ stat.FireResist, stat.LightningResist, stat.ColdResist, stat.PoisonResist, } lowest := 0 for idx, statID := range resistStats { itemStat, found := it.FindStat(statID, 0) if !found { return 0 } if idx == 0 || itemStat.Value < lowest { lowest = itemStat.Value } } return lowest } const aggregateLayerFastPathLimit = 64 func evaluatePositiveStatSum(it data.Item, statID stat.ID, maxLayer int) int { // Current aggregate aliases fit in a small stack bitmap; retain the general // lookup path if additional layers later exceed that bound. if maxLayer >= aggregateLayerFastPathLimit { total := 0 for layer := 0; layer <= maxLayer; layer++ { if itemStat, found := it.FindStat(statID, layer); found && itemStat.Value > 0 { total += itemStat.Value } } return total } var foundLayers [aggregateLayerFastPathLimit]bool total := 0 for _, itemStat := range it.Stats { if itemStat.ID != statID || itemStat.Layer < 0 || itemStat.Layer > maxLayer || foundLayers[itemStat.Layer] { continue } foundLayers[itemStat.Layer] = true if itemStat.Value > 0 { total += itemStat.Value } } for _, itemStat := range it.BaseStats { if itemStat.ID != statID || itemStat.Layer < 0 || itemStat.Layer > maxLayer || foundLayers[itemStat.Layer] { continue } foundLayers[itemStat.Layer] = true if itemStat.Value > 0 { total += itemStat.Value } } return total } func maxAliasLayer(statID stat.ID) int { maxLayer := 0 for _, statData := range statAliases { if len(statData) == 0 || stat.ID(statData[0]) != statID { continue } layer := 0 if len(statData) > 1 { layer = statData[1] } if layer > maxLayer { maxLayer = layer } } return maxLayer } // 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 }