From 417fc41a02240d5b6dd0c5995d41222a92538bee Mon Sep 17 00:00:00 2001 From: stelzo Date: Tue, 16 Jan 2024 14:23:51 +0100 Subject: [PATCH] add condition tree --- mapping.go | 2 +- mapping_test.go | 192 +++++++++++++++++++++++++++++------- parse.go | 255 ++++++++++++++++++++++++++++++++++++++++++------ parse_types.go | 22 +++++ 4 files changed, 406 insertions(+), 65 deletions(-) diff --git a/mapping.go b/mapping.go index 4a48b8d..47eff23 100644 --- a/mapping.go +++ b/mapping.go @@ -162,7 +162,7 @@ func MapItems(data *JSONGameData, langs *map[string]LangDict) []MappedMultilangI } if len(item.Criteria) != 0 && mappedItems[idx].Type.Name["de"] != "Verwendbarer Temporis-Gegenstand" { // TODO Temporis got some weird conditions, need to play to see the items, not in normal game - mappedItems[idx].Conditions = ParseCondition(item.Criteria, langs, data) + mappedItems[idx].Conditions, mappedItems[idx].ConditionTree = ParseCondition(item.Criteria, langs, data) } } diff --git a/mapping_test.go b/mapping_test.go index e9959f3..bc19f3a 100644 --- a/mapping_test.go +++ b/mapping_test.go @@ -1,9 +1,11 @@ package dodumap import ( + "fmt" "log" "os" "path/filepath" + "strings" "testing" ) @@ -73,73 +75,191 @@ func TestParseSigness4(t *testing.T) { } } +func conditionTreeDepth(conditionTree *ConditionTreeNodeMapped) int { + if conditionTree == nil { + return 0 + } + + // has many children + if len(conditionTree.Children) > 0 { + max := 0 + for _, child := range conditionTree.Children { + depth := conditionTreeDepth(child) + if depth > max { + max = depth + } + } + return max + 1 + } + + return 1 +} + +func printAtomicCondition(condition MappedMultilangCondition) string { + return fmt.Sprintf("%s %s %d", condition.Templated["de"], condition.Operator, condition.Value) +} + +func printTreeToString(node *ConditionTreeNodeMapped, level int) string { + if node == nil { + return "" + } + + var output string + if !node.IsOperand { + output += fmt.Sprintf("%s%s\n", strings.Repeat(" ", level*2), *node.Relation) + for _, child := range node.Children { + output += printTreeToString(child, level+1) + } + } else { + output += fmt.Sprintf("%s%s\n", strings.Repeat(" ", level*2), printAtomicCondition(*node.Value)) + for _, child := range node.Children { + output += printTreeToString(child, level+1) + } + } + return output +} + func TestParseConditionSimple(t *testing.T) { - condition := ParseCondition("cs<25", &TestingLangs, TestingData) + oldConditions, conditionTree := ParseCondition("cs<25", &TestingLangs, TestingData) - if len(condition) != 1 { - t.Errorf("condition length is not 1: %d", len(condition)) + if len(oldConditions) != 1 { + t.Errorf("condition length is not 1: %d", len(oldConditions)) } - if condition[0].Operator != "<" { - t.Errorf("operator is not <: %s", condition[0].Operator) + if oldConditions[0].Operator != "<" { + t.Errorf("operator is not <: %s", oldConditions[0].Operator) } - if condition[0].Value != 25 { - t.Errorf("value is not 25: %d", condition[0].Value) + if oldConditions[0].Value != 25 { + t.Errorf("value is not 25: %d", oldConditions[0].Value) } - if condition[0].Templated["de"] != "Stärke" { - t.Errorf("templated is not Stärke: %s", condition[0].Templated["de"]) + if oldConditions[0].Templated["de"] != "Stärke" { + t.Errorf("templated is not Stärke: %s", oldConditions[0].Templated["de"]) + } + + depth := conditionTreeDepth(conditionTree) + if depth != 1 { + t.Errorf("conditionTree depth is not 1: %d\n%s", depth, printTreeToString(conditionTree, 0)) + } + + condition := conditionTree.Value + + if condition.Operator != "<" { + t.Errorf("operator is not <: %s", condition.Operator) + } + + if condition.Value != 25 { + t.Errorf("value is not 25: %d", condition.Value) + } + if condition.Templated["de"] != "Stärke" { + t.Errorf("templated is not Stärke: %s", condition.Templated["de"]) } } func TestParseConditionMulti(t *testing.T) { - condition := ParseCondition("CS>80&CV>40&CA>40", &TestingLangs, TestingData) + toParse := "CS>80&CV>40&CA>40" + oldConditions, conditionTree := ParseCondition(toParse, &TestingLangs, TestingData) + + if len(oldConditions) != 3 { + t.Errorf("condition length is not 3: %d", len(oldConditions)) + } - if len(condition) != 3 { - t.Errorf("condition length is not 3: %d", len(condition)) + if oldConditions[0].Operator != ">" { + t.Errorf("operator is not >: %s", oldConditions[0].Operator) + } + if oldConditions[0].Value != 80 { + t.Errorf("value is not 80: %d", oldConditions[0].Value) + } + if oldConditions[0].Templated["de"] != "Stärke" { + t.Errorf("templated is not Stärke: %s", oldConditions[0].Templated["de"]) } - if condition[0].Operator != ">" { - t.Errorf("operator is not >: %s", condition[0].Operator) + if oldConditions[1].Operator != ">" { + t.Errorf("operator is not >: %s", oldConditions[1].Operator) } - if condition[0].Value != 80 { - t.Errorf("value is not 80: %d", condition[0].Value) + if oldConditions[1].Value != 40 { + t.Errorf("value is not 40: %d", oldConditions[1].Value) } - if condition[0].Templated["de"] != "Stärke" { - t.Errorf("templated is not Stärke: %s", condition[0].Templated["de"]) + if oldConditions[1].Templated["de"] != "Vitalität" { + t.Errorf("templated is not Vitalität: %s", oldConditions[1].Templated["de"]) } - if condition[1].Operator != ">" { - t.Errorf("operator is not >: %s", condition[1].Operator) + if oldConditions[2].Operator != ">" { + t.Errorf("operator is not >: %s", oldConditions[2].Operator) } - if condition[1].Value != 40 { - t.Errorf("value is not 40: %d", condition[1].Value) + if oldConditions[2].Value != 40 { + t.Errorf("value is not 40: %d", oldConditions[2].Value) } - if condition[1].Templated["de"] != "Vitalität" { - t.Errorf("templated is not Vitalität: %s", condition[1].Templated["de"]) + if oldConditions[2].Templated["de"] != "Flinkheit" { + t.Errorf("templated is not Flinkheit: %s", oldConditions[2].Templated["de"]) } - if condition[2].Operator != ">" { - t.Errorf("operator is not >: %s", condition[2].Operator) + depth := conditionTreeDepth(conditionTree) + if depth != 3 { + t.Errorf("conditionTree depth is not 1: %d\n%s", depth, printTreeToString(conditionTree, 0)) } - if condition[2].Value != 40 { - t.Errorf("value is not 40: %d", condition[2].Value) + + if conditionTree.Value != nil { + t.Errorf("expr is nested, must start with operator: %s", conditionTree.Value.Element) } - if condition[2].Templated["de"] != "Flinkheit" { - t.Errorf("templated is not Flinkheit: %s", condition[2].Templated["de"]) + + expected := `and + and + Stärke > 80 + Vitalität > 40 + Flinkheit > 40 +` + + if printTreeToString(conditionTree, 0) != expected { + t.Errorf("conditionTree is not as expected. expression: %s, expected tree: \n%s\nbut is:\n%s", toParse, expected, printTreeToString(conditionTree, 0)) } } -func TestDeleteNumHash(t *testing.T) { - effect_name := DeleteDamageFormatter("Austauschbar ab: #1") - if effect_name != "Austauschbar ab:" { - t.Errorf("output is not as expected: %s", effect_name) +func TestParseOrAndConditionMulti(t *testing.T) { + toParse := "CS>80&(CV>40|CA>40)" + oldConditions, conditionTree := ParseCondition(toParse, &TestingLangs, TestingData) + + if len(oldConditions) != 1 { + t.Errorf("condition length is not 1: %d", len(oldConditions)) + } + + depth := conditionTreeDepth(conditionTree) + if depth != 3 { + t.Errorf("conditionTree depth is not 1: %d\n%s", depth, printTreeToString(conditionTree, 0)) + } + + if conditionTree.Value != nil { + t.Errorf("expr is nested, must start with operator: %s", conditionTree.Value.Element) + } + + expected := `and + Stärke > 80 + or + Vitalität > 40 + Flinkheit > 40 +` + + if printTreeToString(conditionTree, 0) != expected { + t.Errorf("conditionTree is not as expected. expression: %s, expected tree: \n%s\nbut is:\n%s", toParse, expected, printTreeToString(conditionTree, 0)) } } func TestParseConditionEmpty(t *testing.T) { - condition := ParseCondition("null", &TestingLangs, TestingData) - if len(condition) > 0 { + toParse := "null" + oldConditions, conditionTree := ParseCondition(toParse, &TestingLangs, TestingData) + + if len(oldConditions) > 0 { t.Errorf("condition should be empty") } + + if conditionTree != nil { + t.Errorf("conditionTree should be empty with condition \"null\"") + } +} + +func TestDeleteNumHash(t *testing.T) { + effect_name := DeleteDamageFormatter("Austauschbar ab: #1") + if effect_name != "Austauschbar ab:" { + t.Errorf("output is not as expected: %s", effect_name) + } } func TestParseSingularPluralFormatterNormal(t *testing.T) { diff --git a/parse.go b/parse.go index 04d945f..7ee05e9 100644 --- a/parse.go +++ b/parse.go @@ -8,8 +8,10 @@ import ( "os" "path/filepath" "regexp" + "slices" "strconv" "strings" + "unicode" "github.com/charmbracelet/log" "github.com/emirpasic/gods/maps/treebidimap" @@ -245,54 +247,251 @@ func ParseEffects(data *JSONGameData, allEffects [][]JSONGameItemPossibleEffect, return mappedAllEffects } -func ParseCondition(condition string, langs *map[string]LangDict, data *JSONGameData) []MappedMultilangCondition { - if condition == "" || (!strings.Contains(condition, "&") && !strings.Contains(condition, "<") && !strings.Contains(condition, ">")) { - return nil +// NewNode creates a new Node +func newNode(value string, nodeType NodeType) *ConditionTreeNode { + return &ConditionTreeNode{ + Value: value, + Type: nodeType, + Children: []*ConditionTreeNode{}, } +} - condition = strings.ReplaceAll(condition, "\n", "") +// AddChild adds a child node +func (n *ConditionTreeNode) AddChild(child *ConditionTreeNode) { + n.Children = append(n.Children, child) +} - lower := strings.ToLower(condition) +func ParseExpression(exp string) *ConditionTreeNode { + var stack []*ConditionTreeNode + var current *ConditionTreeNode + var operandBuilder strings.Builder + + conditionOperators := []rune{'<', '>', '=', '!'} + + for _, char := range exp { + if unicode.IsLetter(char) || unicode.IsDigit(char) || slices.Contains(conditionOperators, char) { + operandBuilder.WriteRune(char) // continue building operand + } else { + if operandBuilder.Len() > 0 { + // finalize and add the operand + if current != nil && current.Type == Operator { + current.AddChild(newNode(operandBuilder.String(), Operand)) + } else { + current = newNode(operandBuilder.String(), Operand) + } + operandBuilder.Reset() + } - var outs []MappedMultilangCondition + switch char { + case '(': + if current != nil { + stack = append(stack, current) + } + current = nil + case ')': + if len(stack) > 0 { + parent := stack[len(stack)-1] + stack = stack[:len(stack)-1] + parent.AddChild(current) + current = parent + } + case '&', '|': // expression operators + operator := newNode(string(char), Operator) + if current != nil { + operator.AddChild(current) + } + current = operator + } + } + } - var parts []string - if strings.Contains(lower, "&") { - parts = strings.Split(lower, "&") - } else { - parts = []string{lower} + if operandBuilder.Len() > 0 { + if current != nil && current.Type == Operator { + current.AddChild(newNode(operandBuilder.String(), Operand)) + } else { + current = newNode(operandBuilder.String(), Operand) + } } + return current +} + +func atomicCondition(expression string, langs *map[string]LangDict, data *JSONGameData) (bool, MappedMultilangCondition) { operators := []string{"<", ">", "=", "!"} - for _, part := range parts { - var out MappedMultilangCondition - out.Templated = make(map[string]string) - - foundCond := false - for _, operator := range operators { // try every known operator against it - if strings.Contains(part, operator) { - var outTmp MappedMultilangCondition - outTmp.Templated = make(map[string]string) - foundConditionElement := ConditionWithOperator(part, operator, langs, &out, data) - if foundConditionElement { - foundCond = true - } + var out MappedMultilangCondition + out.Templated = make(map[string]string) + + foundCond := false + for _, operator := range operators { // try every known operator against it + if strings.Contains(expression, operator) { + foundConditionElement := ConditionWithOperator(expression, operator, langs, &out, data) + if foundConditionElement { + foundCond = true + break } } + } - if foundCond { - outs = append(outs, out) + return foundCond, out +} + +func removeUnsupportedExpressions(node *ConditionTreeNode, langs *map[string]LangDict, data *JSONGameData) *ConditionTreeNode { + if node == nil { + return nil + } + + // If the node is an operand and not supported, return nil + validCond, _ := atomicCondition(node.Value, langs, data) + if node.Type == Operand && !validCond { + return nil + } + + // Process children + var validChildren []*ConditionTreeNode + for _, child := range node.Children { + processedChild := removeUnsupportedExpressions(child, langs, data) + if processedChild != nil { + validChildren = append(validChildren, processedChild) } } + node.Children = validChildren + + return node +} - if len(outs) == 0 { +func simplifyTree(node *ConditionTreeNode) *ConditionTreeNode { + if node == nil { return nil } - return outs + // Process children + var validChildren []*ConditionTreeNode + for _, child := range node.Children { + processedChild := simplifyTree(child) + if processedChild != nil { + validChildren = append(validChildren, processedChild) + } + } + node.Children = validChildren + + // If an operator node has only one child, replace it with its child + if node.Type == Operator && len(node.Children) == 1 { + return node.Children[0] + } + + return node +} + +func buildMappedConditionTree(out **ConditionTreeNodeMapped, root *ConditionTreeNode, langs *map[string]LangDict, data *JSONGameData) { + if root == nil { + return + } + + if root.Type == Operand { + foundCond, mappedCond := atomicCondition(root.Value, langs, data) + if !foundCond { + log.Fatal("condition not found, should be handled before") + } + + if *out == nil { + *out = new(ConditionTreeNodeMapped) + } + + (*out).Value = &mappedCond + (*out).IsOperand = true + return + } else if root.Type == Operator { + if *out == nil { + *out = new(ConditionTreeNodeMapped) + } + (*out).IsOperand = false + if root.Value == "&" { + (*out).Relation = new(string) + *(*out).Relation = "and" + } else if root.Value == "|" { + (*out).Relation = new(string) + *(*out).Relation = "or" + } else { + log.Fatal("unknown operator") + } + (*out).Children = make([]*ConditionTreeNodeMapped, len(root.Children)) + for i, child := range root.Children { + childOut := new(*ConditionTreeNodeMapped) + buildMappedConditionTree(childOut, child, langs, data) + (*out).Children[i] = *childOut + } + return + } else { + log.Fatal("unknown node type") + } } +func PrintTree(node *ConditionTreeNode, level int) { + if node == nil { + return + } + + fmt.Printf("%s%s\n", strings.Repeat(" ", level*2), node.Value) + for _, child := range node.Children { + PrintTree(child, level+1) + } +} + +func buildHistoricAndConnectionArray(mappedTree *ConditionTreeNodeMapped, out *[]MappedMultilangCondition) { + if mappedTree == nil { + return + } + + if mappedTree.IsOperand { + *out = append(*out, *mappedTree.Value) + return + } + + if *mappedTree.Relation == "and" { + for _, child := range mappedTree.Children { + buildHistoricAndConnectionArray(child, out) + } + } +} + +func ParseCondition(condition string, langs *map[string]LangDict, data *JSONGameData) ([]MappedMultilangCondition, *ConditionTreeNodeMapped) { + if condition == "" || (!strings.Contains(condition, "&") && !strings.Contains(condition, "|") && !strings.Contains(condition, "<") && !strings.Contains(condition, ">")) { + return nil, nil + } + + // parse into ast + tree := ParseExpression(condition) + + // strip tree to only known conditions + tree = removeUnsupportedExpressions(tree, langs, data) + tree = simplifyTree(tree) + + if tree == nil { + return nil, nil + } + + // convert to mapped tree + mappedTree := new(*ConditionTreeNodeMapped) + buildMappedConditionTree(mappedTree, tree, langs, data) + + if *mappedTree == nil { + log.Fatal("mapped tree is nil") + } + + // for historical reasons, still return the old format but only for &-connected conditions + // check the tree and combine all children that are connected with & to a single array + var mappedConditions []MappedMultilangCondition + buildHistoricAndConnectionArray(*mappedTree, &mappedConditions) + if len(mappedConditions) == 0 { + mappedConditions = nil + } + + return mappedConditions, *mappedTree +} + +// TODO: previous "conditions" convert to only be with simple & operator + type HasId interface { GetID() int } diff --git a/parse_types.go b/parse_types.go index 9c2ad4d..cf1f2ee 100644 --- a/parse_types.go +++ b/parse_types.go @@ -8,6 +8,27 @@ type MappedMultilangCondition struct { Templated map[string]string `json:"templated"` } +// NodeType defines the type of a node - either an operator or an operand +type NodeType int + +const ( + Operand NodeType = iota // like "a" in "a & b" + Operator // like "&" in "a & b" +) + +type ConditionTreeNode struct { + Value string + Type NodeType + Children []*ConditionTreeNode +} + +type ConditionTreeNodeMapped struct { + Value *MappedMultilangCondition `json:"value"` + IsOperand bool `json:"is_operand"` + Relation *string `json:"relation"` // "and" or "or" + Children []*ConditionTreeNodeMapped `json:"children"` +} + type MappedMultilangRecipe struct { ResultId int `json:"result_id"` Entries []MappedMultilangRecipeEntry `json:"entries"` @@ -71,6 +92,7 @@ type MappedMultilangItem struct { Name map[string]string `json:"name"` Image string `json:"image"` Conditions []MappedMultilangCondition `json:"conditions"` + ConditionTree *ConditionTreeNodeMapped `json:"condition_tree"` Level int `json:"level"` UsedInRecipes []int `json:"used_in_recipes"` Characteristics []MappedMultilangCharacteristic `json:"characteristics"`