fixed multiple bugs with graphing inputs not working properly

This commit is contained in:
Grimsace
2026-02-19 13:53:27 -06:00
parent 94adcb152c
commit 21f0c54629
2 changed files with 270 additions and 155 deletions
+1
View File
@@ -11,6 +11,7 @@ binaries/
# Test binary, built with `go test -c` # Test binary, built with `go test -c`
*.test *.test
*_test.go
# Code coverage profiles and other test artifacts # Code coverage profiles and other test artifacts
*.out *.out
+242 -128
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"math"
"regexp" "regexp"
"sort" "sort"
"strconv" "strconv"
@@ -19,6 +20,16 @@ type DiceStatistics struct {
MostCommon int // most common (median) value MostCommon int // most common (median) value
} }
// Distribution represents the frequency distribution of outcomes
type Distribution map[int]int
// Regex patterns for parsing
var (
diceTokenPattern = regexp.MustCompile(`^([HL])?(\d*)d(\d+)([HL])?`)
// Updated numberTokenPattern to include optional decimal part
numberTokenPattern = regexp.MustCompile(`^(\d+(\.\d+)?)`)
)
// CalculateDiceStatistics calculates the theoretical distribution of possible outcomes for a dice expression // CalculateDiceStatistics calculates the theoretical distribution of possible outcomes for a dice expression
func CalculateDiceStatistics(expression string) (*DiceStatistics, error) { func CalculateDiceStatistics(expression string) (*DiceStatistics, error) {
expression = strings.TrimSpace(expression) expression = strings.TrimSpace(expression)
@@ -26,33 +37,42 @@ func CalculateDiceStatistics(expression string) (*DiceStatistics, error) {
return nil, fmt.Errorf("empty expression") return nil, fmt.Errorf("empty expression")
} }
// Parse the expression to extract terms parser := &statParser{expr: expression, pos: 0}
terms, err := parseTerms(expression) outcomes, err := parser.parseExpression()
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Calculate all possible outcomes and their frequencies parser.skipWhitespace()
outcomes := calculateOutcomeDistribution(terms) if parser.pos < len(parser.expr) {
return nil, fmt.Errorf("unexpected character at position %d: '%c'", parser.pos, parser.expr[parser.pos])
}
if len(outcomes) == 0 { if len(outcomes) == 0 {
return nil, fmt.Errorf("no valid outcomes for expression") return nil, fmt.Errorf("no valid outcomes for expression")
} }
// Find min and max // Find min and max
minVal := -1 minVal := 0
maxVal := -1 maxVal := 0
first := true
totalCount := 0 totalCount := 0
for value, count := range outcomes { for value, count := range outcomes {
totalCount += count totalCount += count
if minVal == -1 || value < minVal { if first {
minVal = value
maxVal = value
first = false
} else {
if value < minVal {
minVal = value minVal = value
} }
if maxVal == -1 || value > maxVal { if value > maxVal {
maxVal = value maxVal = value
} }
} }
}
// Calculate percentages // Calculate percentages
percentages := make(map[int]float64) percentages := make(map[int]float64)
@@ -74,60 +94,178 @@ func CalculateDiceStatistics(expression string) (*DiceStatistics, error) {
return stats, nil return stats, nil
} }
// Term represents a single term in the expression (dice roll or constant) // statParser implementation
type Term struct { type statParser struct {
isDice bool expr string
count int // number of dice pos int
sides int // sides per die
modifier string // "" for sum, "H" for highest, "L" for lowest
value int // constant value if not dice
op string // operation before this term: "+", "-"
} }
// parseTerms parses a dice expression into terms func (p *statParser) skipWhitespace() {
// parseTerms parses a dice expression into terms, handling H/L flexibly for p.pos < len(p.expr) && (p.expr[p.pos] == ' ' || p.expr[p.pos] == '\t') {
func parseTerms(expression string) ([]Term, error) { p.pos++
var terms []Term }
}
// Remove spaces // parseExpression handles addition and subtraction
expression = strings.TrimSpace(expression) func (p *statParser) parseExpression() (Distribution, error) {
left, err := p.parseTerm()
// Split by + and - while keeping the operators if err != nil {
parts := regexp.MustCompile(`([+\-])`).Split(expression, -1) return nil, err
currentOp := "+"
pendingModifier := "" // Store H or L to apply to the next dice roll
// Pattern to match: optional H/L prefix, count, d, sides, optional H/L suffix
dicePattern := regexp.MustCompile(`^([HL])?(\d*)d(\d+)([HL])?$`)
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
} }
// Check if this is an operator for {
if part == "+" || part == "-" { p.skipWhitespace()
currentOp = part if p.pos >= len(p.expr) {
continue break
} }
// Check for standalone H or L modifier if p.expr[p.pos] == '+' {
if part == "H" || part == "L" { p.pos++
pendingModifier = part right, err := p.parseTerm()
continue if err != nil {
return nil, err
}
left = addDist(left, right)
} else if p.expr[p.pos] == '-' {
p.pos++
right, err := p.parseTerm()
if err != nil {
return nil, err
}
left = subDist(left, right)
} else {
break
}
} }
// Try to match dice notation return left, nil
matches := dicePattern.FindStringSubmatch(part) }
// parseTerm handles multiplication, division and implicit multiplication
func (p *statParser) parseTerm() (Distribution, error) {
left, err := p.parsePower()
if err != nil {
return nil, err
}
for {
p.skipWhitespace()
if p.pos >= len(p.expr) {
break
}
c := p.expr[p.pos]
if c == '*' {
p.pos++
right, err := p.parsePower()
if err != nil {
return nil, err
}
left = multDist(left, right)
} else if c == '/' {
p.pos++
right, err := p.parsePower()
if err != nil {
return nil, err
}
left = divDist(left, right)
} else if c == '(' || (c >= '0' && c <= '9') || c == 'd' || c == 'H' || c == 'L' {
// Implicit multiplication for things that look like factors
right, err := p.parsePower()
if err != nil {
return nil, err
}
left = multDist(left, right)
} else {
break
}
}
return left, nil
}
// parsePower handles exponentiation
func (p *statParser) parsePower() (Distribution, error) {
left, err := p.parseFactor()
if err != nil {
return nil, err
}
for {
p.skipWhitespace()
if p.pos >= len(p.expr) {
break
}
if p.expr[p.pos] == '^' {
p.pos++
right, err := p.parseFactor() // Left-associative to match calculator
if err != nil {
return nil, err
}
left = powDist(left, right)
} else {
break
}
}
return left, nil
}
// parseFactor handles parentheses, dice, and numbers
func (p *statParser) parseFactor() (Distribution, error) {
p.skipWhitespace()
if p.pos >= len(p.expr) {
return nil, fmt.Errorf("unexpected end of expression")
}
// Parentheses
if p.expr[p.pos] == '(' {
p.pos++
dist, err := p.parseExpression()
if err != nil {
return nil, err
}
p.skipWhitespace()
if p.pos >= len(p.expr) || p.expr[p.pos] != ')' {
return nil, fmt.Errorf("missing closing parenthesis")
}
p.pos++
return dist, nil
}
// Try Dice Pattern
remaining := p.expr[p.pos:]
if loc := diceTokenPattern.FindStringIndex(remaining); loc != nil {
token := remaining[loc[0]:loc[1]]
p.pos += loc[1]
return parseDiceToken(token)
}
// Try Number Pattern
if loc := numberTokenPattern.FindStringIndex(remaining); loc != nil {
token := remaining[loc[0]:loc[1]]
p.pos += loc[1]
// Parse as float then cast to int (truncate/floor) to handle buttons like "."
valFloat, err := strconv.ParseFloat(token, 64)
if err != nil {
return nil, fmt.Errorf("invalid number: %s", token)
}
return Distribution{int(valFloat): 1}, nil
}
return nil, fmt.Errorf("unexpected character: %c", p.expr[p.pos])
}
func parseDiceToken(token string) (Distribution, error) {
matches := diceTokenPattern.FindStringSubmatch(token)
if matches != nil { if matches != nil {
// Extract components // It is a dice expression
prefixModifier := matches[1] // H or L before the dice prefixModifier := matches[1]
countStr := matches[2] countStr := matches[2]
sidesStr := matches[3] sidesStr := matches[3]
suffixModifier := matches[4] // H or L after the dice suffixModifier := matches[4]
count := 1 count := 1
if countStr != "" { if countStr != "" {
@@ -143,106 +281,82 @@ func parseTerms(expression string) ([]Term, error) {
return nil, err return nil, err
} }
if sides <= 0 || count <= 0 {
return nil, fmt.Errorf("invalid dice: %dd%d", count, sides)
}
// Determine which modifier to use (priority: suffix > prefix > pending)
modifier := "" modifier := ""
if suffixModifier != "" { if suffixModifier != "" {
modifier = suffixModifier modifier = suffixModifier
} else if prefixModifier != "" { } else if prefixModifier != "" {
modifier = prefixModifier modifier = prefixModifier
} else if pendingModifier != "" {
modifier = pendingModifier
} }
terms = append(terms, Term{ return getDiceOutcomes(count, sides, modifier), nil
isDice: true,
count: count,
sides: sides,
modifier: modifier,
op: currentOp,
})
currentOp = "+"
pendingModifier = ""
} else {
// Try to parse as constant (but reset pending modifier if it was set)
if pendingModifier != "" {
return nil, fmt.Errorf("modifier %s can only be applied to dice rolls", pendingModifier)
} }
val, err := strconv.Atoi(part) return nil, fmt.Errorf("invalid dice term: %s", token)
if err != nil {
return nil, fmt.Errorf("invalid term: %s", part)
}
terms = append(terms, Term{
isDice: false,
value: val,
op: currentOp,
})
currentOp = "+"
}
}
// If we ended with a pending modifier, that's an error
if pendingModifier != "" {
return nil, fmt.Errorf("modifier %s at end of expression with no dice roll to apply to", pendingModifier)
}
return terms, nil
} }
// calculateOutcomeDistribution calculates all possible outcomes and their frequencies // Operations on Distributions
func calculateOutcomeDistribution(terms []Term) map[int]int {
// Start with base case: single outcome of 0 with 1 way to achieve it
outcomes := map[int]int{0: 1}
for _, term := range terms { func addDist(a, b Distribution) Distribution {
outcomes = applyTerm(outcomes, term) res := make(Distribution)
for valA, countA := range a {
for valB, countB := range b {
res[valA+valB] += countA * countB
} }
}
return outcomes return res
} }
// applyTerm applies a term to the current outcomes distribution func subDist(a, b Distribution) Distribution {
func applyTerm(currentOutcomes map[int]int, term Term) map[int]int { res := make(Distribution)
newOutcomes := make(map[int]int) for valA, countA := range a {
for valB, countB := range b {
res[valA-valB] += countA * countB
}
}
return res
}
if term.isDice { func multDist(a, b Distribution) Distribution {
// Get all possible values for this dice roll res := make(Distribution)
diceOutcomes := getDiceOutcomes(term.count, term.sides, term.modifier) for valA, countA := range a {
for valB, countB := range b {
res[valA*valB] += countA * countB
}
}
return res
}
// Combine with current outcomes func divDist(a, b Distribution) Distribution {
for currentVal, currentCount := range currentOutcomes { res := make(Distribution)
for diceVal, diceCount := range diceOutcomes { for valA, countA := range a {
var resultVal int for valB, countB := range b {
if term.op == "-" { if valB == 0 {
resultVal = currentVal - diceVal continue // Division by zero yields no outcome
}
res[valA/valB] += countA * countB
}
}
return res
}
func powDist(a, b Distribution) Distribution {
res := make(Distribution)
for valA, countA := range a {
for valB, countB := range b {
// Integer exponentiation
// Standard behavior for non-negative exponents
// Negative exponents with int base result in 0 (unless -1, 1).
val := 0
if valB >= 0 {
val = int(math.Pow(float64(valA), float64(valB)))
} else { } else {
resultVal = currentVal + diceVal // Integer division for 1/(a^-b) usually 0
val = int(math.Pow(float64(valA), float64(valB)))
} }
res[val] += countA * countB
newOutcomes[resultVal] += currentCount * diceCount
} }
} }
} else { return res
// Constant value
for currentVal, currentCount := range currentOutcomes {
var resultVal int
if term.op == "-" {
resultVal = currentVal - term.value
} else {
resultVal = currentVal + term.value
}
newOutcomes[resultVal] += currentCount
}
}
return newOutcomes
} }
// getDiceOutcomes returns a map of all possible outcomes for a dice roll and their frequencies // getDiceOutcomes returns a map of all possible outcomes for a dice roll and their frequencies