502 lines
14 KiB
Go
502 lines
14 KiB
Go
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,
|
||
// don’t 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
|
||
}
|