From 450fccb5e9b55fef58a4d2d78be7605afc713725 Mon Sep 17 00:00:00 2001 From: vietdungdev Date: Fri, 22 May 2026 19:45:01 +0700 Subject: [PATCH] update nip rule --- pkg/nip/rule.go | 95 +++++++++++-- pkg/nip/rule_test.go | 329 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 411 insertions(+), 13 deletions(-) diff --git a/pkg/nip/rule.go b/pkg/nip/rule.go index 0ec5de8..994cbbf 100644 --- a/pkg/nip/rule.go +++ b/pkg/nip/rule.go @@ -118,6 +118,24 @@ func (r Rules) EvaluateTiers(it data.Item, tierRulesIndexes []int) (Rule, Rule) 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) @@ -252,14 +270,19 @@ func (r Rule) Evaluate(it data.Item) (RuleResult, error) { case "name": stage1Props["name"] = it.ID case "flag": - // 0x400000 (eth) | 0x4000000 (runeword) kolbot style + // 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 |= 0x400000 + currentFlag |= flagBitEthereal } if it.IsRuneword { - currentFlag |= 0x4000000 + currentFlag |= flagBitRuneword } stage1Props["flag"] = currentFlag @@ -489,16 +512,47 @@ func replaceStringPropertiesInStage1(stage1 string) (string, error) { case "name": replaceWith = strings.ReplaceAll(prop[0], prop[4], fmt.Sprintf("%d", item.GetIDByName(prop[4]))) case "flag": - val := 0 - switch strings.ToLower(prop[4]) { + // [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": - val = 0x4000000 + bit = flagBitRuneword case "ethereal": - val = 0x400000 + bit = flagBitEthereal default: - val = 1 + 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)) } - replaceWith = strings.ReplaceAll(prop[0], prop[4], fmt.Sprintf("%d", val)) case "prefix", "suffix": // Handle prefix/suffix IDs replaceWith = strings.ReplaceAll(prop[0], prop[4], prop[4]) @@ -541,9 +595,8 @@ func (r Rule) ValidateStats() error { 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++ { + for layer := 0; layer <= maxClassSkillsLayer; layer++ { if itemStat, found := it.FindStat(stat.AddClassSkills, layer); found && itemStat.Value > 0 { totalClassSkills += itemStat.Value } @@ -554,9 +607,8 @@ func evaluateClassSkillsSum(it data.Item) int { 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++ { + for layer := 0; layer <= maxSkillTabLayer; layer++ { if itemStat, found := it.FindStat(stat.AddSkillTab, layer); found && itemStat.Value > 0 { totalSkillTabs += itemStat.Value } @@ -565,6 +617,23 @@ func evaluateSkillTabSum(it data.Item) int { return totalSkillTabs } +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 diff --git a/pkg/nip/rule_test.go b/pkg/nip/rule_test.go index 78b6ece..da4c09a 100644 --- a/pkg/nip/rule_test.go +++ b/pkg/nip/rule_test.go @@ -1,6 +1,8 @@ package nip import ( + "sort" + "strconv" "testing" "github.com/hectorgimenez/d2go/pkg/data" @@ -755,6 +757,112 @@ func TestRule_Evaluate(t *testing.T) { }, want: RuleResultNoMatch, }, + // Regression tests for the ethereal+runeword [flag] bitmask interaction. + // Previously "[flag] == ethereal" rewrote to a scalar compare and missed + // ethereal runewords (flag = eth | rw). The rewriter now enumerates both + // states the chosen bit can produce. + { + name: "Flag: == ethereal matches a plain ethereal item", + fields: fields{ + RawLine: "[type] == armor && [flag] == ethereal", + Enabled: true, + }, + args: args{ + item: data.Item{ + ID: 373, + Name: "mageplate", + Identified: true, + Ethereal: true, + IsRuneword: false, + }, + }, + want: RuleResultFullMatch, + }, + { + name: "Flag: == ethereal also matches an ethereal RUNEWORD (regression: was NoMatch)", + fields: fields{ + RawLine: "[type] == armor && [flag] == ethereal", + Enabled: true, + }, + args: args{ + item: data.Item{ + ID: 373, + Name: "mageplate", + Identified: true, + Ethereal: true, + IsRuneword: true, + }, + }, + want: RuleResultFullMatch, + }, + { + name: "Flag: != ethereal correctly excludes ethereal runeword", + fields: fields{ + RawLine: "[type] == armor && [flag] != ethereal", + Enabled: true, + }, + args: args{ + item: data.Item{ + ID: 373, + Name: "mageplate", + Identified: true, + Ethereal: true, + IsRuneword: true, + }, + }, + want: RuleResultNoMatch, + }, + { + name: "Flag: != ethereal matches a non-ethereal runeword", + fields: fields{ + RawLine: "[type] == armor && [flag] != ethereal", + Enabled: true, + }, + args: args{ + item: data.Item{ + ID: 373, + Name: "mageplate", + Identified: true, + Ethereal: false, + IsRuneword: true, + }, + }, + want: RuleResultFullMatch, + }, + { + name: "Flag: == runeword matches an ethereal runeword", + fields: fields{ + RawLine: "[type] == armor && [flag] == runeword", + Enabled: true, + }, + args: args{ + item: data.Item{ + ID: 373, + Name: "mageplate", + Identified: true, + Ethereal: true, + IsRuneword: true, + }, + }, + want: RuleResultFullMatch, + }, + { + name: "Flag: != runeword excludes ethereal runeword", + fields: fields{ + RawLine: "[type] == armor && [flag] != runeword", + Enabled: true, + }, + args: args{ + item: data.Item{ + ID: 373, + Name: "mageplate", + Identified: true, + Ethereal: true, + IsRuneword: true, + }, + }, + want: RuleResultNoMatch, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -804,6 +912,227 @@ func TestNew(t *testing.T) { } } +func TestAggregateSkillRulesIncludeHighestAliasLayers(t *testing.T) { + tests := []struct { + name string + rule string + stat stat.ID + layer int + }{ + { + name: "class skills include warlock layer", + rule: "[quality] >= normal # [itemaddclassskills] >= 3", + stat: stat.AddClassSkills, + layer: 7, + }, + { + name: "skill tabs include demon layer", + rule: "[quality] >= normal # [itemaddskilltab] >= 3", + stat: stat.AddSkillTab, + layer: 56, + }, + { + name: "skill tabs include eldritch layer", + rule: "[quality] >= normal # [itemaddskilltab] >= 3", + stat: stat.AddSkillTab, + layer: 57, + }, + { + name: "skill tabs include chaos layer", + rule: "[quality] >= normal # [itemaddskilltab] >= 3", + stat: stat.AddSkillTab, + layer: 58, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rule, err := NewRule(tt.rule, "test.nip", 1) + require.NoError(t, err) + + result, err := rule.Evaluate(data.Item{ + ID: 516, + Name: "healingpotion", + Quality: item.QualityMagic, + Identified: true, + Stats: []stat.Data{ + {ID: tt.stat, Layer: tt.layer, Value: 3}, + }, + }) + + require.NoError(t, err) + require.Equal(t, RuleResultFullMatch, result) + }) + } +} + +func TestWarlockSkillAliasesReadExactLayers(t *testing.T) { + tests := []struct { + name string + rule string + stat stat.ID + layer int + }{ + { + name: "warlock class skills", + rule: "[quality] >= normal # [warlockskills] >= 3", + stat: stat.AddClassSkills, + layer: 7, + }, + { + name: "itemadd warlock class skills", + rule: "[quality] >= normal # [itemaddwarlockskills] >= 3", + stat: stat.AddClassSkills, + layer: 7, + }, + { + name: "warlock chaos skill tab", + rule: "[quality] >= normal # [chaosskilltab] >= 3", + stat: stat.AddSkillTab, + layer: 58, + }, + { + name: "itemadd warlock chaos skill tab", + rule: "[quality] >= normal # [itemaddchaosskilltab] >= 3", + stat: stat.AddSkillTab, + layer: 58, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rule, err := NewRule(tt.rule, "test.nip", 1) + require.NoError(t, err) + + result, err := rule.Evaluate(data.Item{ + ID: 516, + Name: "healingpotion", + Quality: item.QualityMagic, + Identified: true, + Stats: []stat.Data{ + {ID: tt.stat, Layer: tt.layer, Value: 3}, + }, + }) + + require.NoError(t, err) + require.Equal(t, RuleResultFullMatch, result) + }) + } +} + +func TestAllResUsesLowestElementalResistance(t *testing.T) { + rule, err := NewRule("[quality] >= normal # [allres] >= 20", "test.nip", 1) + require.NoError(t, err) + + result, err := rule.Evaluate(data.Item{ + ID: 516, + Name: "healingpotion", + Quality: item.QualityMagic, + Identified: true, + Stats: stat.Stats{ + {ID: stat.FireResist, Value: 25}, + {ID: stat.LightningResist, Value: 20}, + {ID: stat.ColdResist, Value: 30}, + {ID: stat.PoisonResist, Value: 35}, + }, + }) + + require.NoError(t, err) + require.Equal(t, RuleResultFullMatch, result) +} + +func TestAllStatAliasesReadFromItemStats(t *testing.T) { + aliases := make([]string, 0, len(statAliases)) + for alias := range statAliases { + aliases = append(aliases, alias) + } + sort.Strings(aliases) + + for _, alias := range aliases { + t.Run(alias, func(t *testing.T) { + if alias == "allres" { + itemToCheck := data.Item{ + ID: 516, + Name: "healingpotion", + Quality: item.QualityMagic, + Identified: true, + Stats: stat.Stats{ + {ID: stat.FireResist, Value: 321}, + {ID: stat.LightningResist, Value: 321}, + {ID: stat.ColdResist, Value: 321}, + {ID: stat.PoisonResist, Value: 321}, + }, + } + requireRuleFullMatch(t, alias, 321, itemToCheck) + return + } + + statData := statAliases[alias] + require.NotEmpty(t, statData) + + statID := stat.ID(statData[0]) + layer := 0 + if len(statData) > 1 { + layer = statData[1] + } + + statsItem, expected := itemWithStat(statID, layer, false) + requireRuleFullMatch(t, alias, expected, statsItem) + + baseStatsItem, expected := itemWithStat(statID, layer, true) + requireRuleFullMatch(t, alias, expected, baseStatsItem) + }) + } +} + +func itemWithStat(statID stat.ID, layer int, base bool) (data.Item, int) { + expected := 321 + statLayer := layer + statValue := expected + + switch statID { + case stat.SkillOnAttack, stat.SkillOnKill, stat.SkillOnDeath, stat.SkillOnHit, stat.SkillOnLevelUp, stat.SkillOnGetHit, stat.ItemChargedSkill: + if layer == 1 || layer == 2 { + skillID := 47 + level := 13 + statLayer = skillID<<6 | level + statValue = 5 + if layer == 1 { + expected = skillID + } else { + expected = level + } + } + } + + itemToCheck := data.Item{ + ID: 516, + Name: "healingpotion", + Quality: item.QualityMagic, + Identified: true, + } + statToCheck := stat.Data{ID: statID, Layer: statLayer, Value: statValue} + if base { + itemToCheck.BaseStats = stat.Stats{statToCheck} + } else { + itemToCheck.Stats = stat.Stats{statToCheck} + } + + return itemToCheck, expected +} + +func requireRuleFullMatch(t *testing.T, alias string, expected int, itemToCheck data.Item) { + t.Helper() + + rule, err := NewRule("[quality] >= normal # ["+alias+"] == "+strconv.Itoa(expected), "test.nip", 1) + require.NoError(t, err) + require.NoError(t, rule.ValidateStats()) + + result, err := rule.Evaluate(itemToCheck) + require.NoError(t, err) + require.Equal(t, RuleResultFullMatch, result) +} + func BenchmarkEvaluate(b *testing.B) { it := data.Item{ ID: 0,