2026-02-12 09:00:14 -06:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
2026-02-19 13:53:27 -06:00
|
|
|
"math"
|
2026-02-12 09:00:14 -06:00
|
|
|
"regexp"
|
|
|
|
|
"sort"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// DiceStatistics holds the theoretical statistics for a dice roll
|
|
|
|
|
type DiceStatistics struct {
|
|
|
|
|
MinValue int
|
|
|
|
|
MaxValue int
|
|
|
|
|
Results map[int]int // outcome -> count of ways to achieve it
|
|
|
|
|
Total int // total number of possible outcomes
|
|
|
|
|
Percentages map[int]float64 // outcome -> percentage
|
2026-02-12 09:10:42 -06:00
|
|
|
Average float64 // average/mean value
|
|
|
|
|
MostCommon int // most common (median) value
|
2026-02-12 09:00:14 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 13:53:27 -06:00
|
|
|
// 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+)?)`)
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-12 09:00:14 -06:00
|
|
|
// CalculateDiceStatistics calculates the theoretical distribution of possible outcomes for a dice expression
|
|
|
|
|
func CalculateDiceStatistics(expression string) (*DiceStatistics, error) {
|
|
|
|
|
expression = strings.TrimSpace(expression)
|
|
|
|
|
if expression == "" {
|
|
|
|
|
return nil, fmt.Errorf("empty expression")
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 13:53:27 -06:00
|
|
|
parser := &statParser{expr: expression, pos: 0}
|
|
|
|
|
outcomes, err := parser.parseExpression()
|
2026-02-12 09:00:14 -06:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 13:53:27 -06:00
|
|
|
parser.skipWhitespace()
|
|
|
|
|
if parser.pos < len(parser.expr) {
|
|
|
|
|
return nil, fmt.Errorf("unexpected character at position %d: '%c'", parser.pos, parser.expr[parser.pos])
|
|
|
|
|
}
|
2026-02-12 09:00:14 -06:00
|
|
|
|
|
|
|
|
if len(outcomes) == 0 {
|
|
|
|
|
return nil, fmt.Errorf("no valid outcomes for expression")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find min and max
|
2026-02-19 13:53:27 -06:00
|
|
|
minVal := 0
|
|
|
|
|
maxVal := 0
|
|
|
|
|
first := true
|
2026-02-12 09:00:14 -06:00
|
|
|
totalCount := 0
|
|
|
|
|
|
|
|
|
|
for value, count := range outcomes {
|
|
|
|
|
totalCount += count
|
2026-02-19 13:53:27 -06:00
|
|
|
if first {
|
2026-02-12 09:00:14 -06:00
|
|
|
minVal = value
|
|
|
|
|
maxVal = value
|
2026-02-19 13:53:27 -06:00
|
|
|
first = false
|
|
|
|
|
} else {
|
|
|
|
|
if value < minVal {
|
|
|
|
|
minVal = value
|
|
|
|
|
}
|
|
|
|
|
if value > maxVal {
|
|
|
|
|
maxVal = value
|
|
|
|
|
}
|
2026-02-12 09:00:14 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate percentages
|
|
|
|
|
percentages := make(map[int]float64)
|
|
|
|
|
for value, count := range outcomes {
|
|
|
|
|
percentages[value] = (float64(count) / float64(totalCount)) * 100
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:10:42 -06:00
|
|
|
stats := &DiceStatistics{
|
2026-02-12 09:00:14 -06:00
|
|
|
MinValue: minVal,
|
|
|
|
|
MaxValue: maxVal,
|
|
|
|
|
Results: outcomes,
|
|
|
|
|
Total: totalCount,
|
|
|
|
|
Percentages: percentages,
|
2026-02-12 09:10:42 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate average and most common value
|
|
|
|
|
stats.calculateAverageAndMedian()
|
|
|
|
|
|
|
|
|
|
return stats, nil
|
2026-02-12 09:00:14 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 13:53:27 -06:00
|
|
|
// statParser implementation
|
|
|
|
|
type statParser struct {
|
|
|
|
|
expr string
|
|
|
|
|
pos int
|
2026-02-12 09:00:14 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 13:53:27 -06:00
|
|
|
func (p *statParser) skipWhitespace() {
|
|
|
|
|
for p.pos < len(p.expr) && (p.expr[p.pos] == ' ' || p.expr[p.pos] == '\t') {
|
|
|
|
|
p.pos++
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-12 09:00:14 -06:00
|
|
|
|
2026-02-19 13:53:27 -06:00
|
|
|
// parseExpression handles addition and subtraction
|
|
|
|
|
func (p *statParser) parseExpression() (Distribution, error) {
|
|
|
|
|
left, err := p.parseTerm()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-02-12 09:22:11 -06:00
|
|
|
|
2026-02-19 13:53:27 -06:00
|
|
|
for {
|
|
|
|
|
p.skipWhitespace()
|
|
|
|
|
if p.pos >= len(p.expr) {
|
|
|
|
|
break
|
2026-02-12 09:00:14 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 13:53:27 -06:00
|
|
|
if p.expr[p.pos] == '+' {
|
|
|
|
|
p.pos++
|
|
|
|
|
right, err := p.parseTerm()
|
2026-02-12 09:00:14 -06:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-02-19 13:53:27 -06:00
|
|
|
left = addDist(left, right)
|
|
|
|
|
} else if p.expr[p.pos] == '-' {
|
|
|
|
|
p.pos++
|
|
|
|
|
right, err := p.parseTerm()
|
2026-02-12 09:00:14 -06:00
|
|
|
if err != nil {
|
2026-02-19 13:53:27 -06:00
|
|
|
return nil, err
|
2026-02-12 09:00:14 -06:00
|
|
|
}
|
2026-02-19 13:53:27 -06:00
|
|
|
left = subDist(left, right)
|
|
|
|
|
} else {
|
|
|
|
|
break
|
2026-02-12 09:00:14 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 13:53:27 -06:00
|
|
|
return left, nil
|
2026-02-12 09:00:14 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 13:53:27 -06:00
|
|
|
// parseTerm handles multiplication, division and implicit multiplication
|
|
|
|
|
func (p *statParser) parseTerm() (Distribution, error) {
|
|
|
|
|
left, err := p.parsePower()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
2026-02-12 09:00:14 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 13:53:27 -06:00
|
|
|
for {
|
|
|
|
|
p.skipWhitespace()
|
|
|
|
|
if p.pos >= len(p.expr) {
|
|
|
|
|
break
|
2026-02-12 09:00:14 -06:00
|
|
|
}
|
2026-02-19 13:53:27 -06:00
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
// It is a dice expression
|
|
|
|
|
prefixModifier := matches[1]
|
|
|
|
|
countStr := matches[2]
|
|
|
|
|
sidesStr := matches[3]
|
|
|
|
|
suffixModifier := matches[4]
|
|
|
|
|
|
|
|
|
|
count := 1
|
|
|
|
|
if countStr != "" {
|
|
|
|
|
c, err := strconv.Atoi(countStr)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
count = c
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sides, err := strconv.Atoi(sidesStr)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
modifier := ""
|
|
|
|
|
if suffixModifier != "" {
|
|
|
|
|
modifier = suffixModifier
|
|
|
|
|
} else if prefixModifier != "" {
|
|
|
|
|
modifier = prefixModifier
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return getDiceOutcomes(count, sides, modifier), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil, fmt.Errorf("invalid dice term: %s", token)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Operations on Distributions
|
|
|
|
|
|
|
|
|
|
func addDist(a, b Distribution) Distribution {
|
|
|
|
|
res := make(Distribution)
|
|
|
|
|
for valA, countA := range a {
|
|
|
|
|
for valB, countB := range b {
|
|
|
|
|
res[valA+valB] += countA * countB
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return res
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func subDist(a, b Distribution) Distribution {
|
|
|
|
|
res := make(Distribution)
|
|
|
|
|
for valA, countA := range a {
|
|
|
|
|
for valB, countB := range b {
|
|
|
|
|
res[valA-valB] += countA * countB
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return res
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func multDist(a, b Distribution) Distribution {
|
|
|
|
|
res := make(Distribution)
|
|
|
|
|
for valA, countA := range a {
|
|
|
|
|
for valB, countB := range b {
|
|
|
|
|
res[valA*valB] += countA * countB
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return res
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func divDist(a, b Distribution) Distribution {
|
|
|
|
|
res := make(Distribution)
|
|
|
|
|
for valA, countA := range a {
|
|
|
|
|
for valB, countB := range b {
|
|
|
|
|
if valB == 0 {
|
|
|
|
|
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)))
|
2026-02-12 09:00:14 -06:00
|
|
|
} else {
|
2026-02-19 13:53:27 -06:00
|
|
|
// Integer division for 1/(a^-b) usually 0
|
|
|
|
|
val = int(math.Pow(float64(valA), float64(valB)))
|
2026-02-12 09:00:14 -06:00
|
|
|
}
|
2026-02-19 13:53:27 -06:00
|
|
|
res[val] += countA * countB
|
2026-02-12 09:00:14 -06:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-19 13:53:27 -06:00
|
|
|
return res
|
2026-02-12 09:00:14 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// getDiceOutcomes returns a map of all possible outcomes for a dice roll and their frequencies
|
|
|
|
|
func getDiceOutcomes(count int, sides int, modifier string) map[int]int {
|
|
|
|
|
outcomes := make(map[int]int)
|
|
|
|
|
|
|
|
|
|
if modifier == "H" {
|
|
|
|
|
// Keep only the highest die
|
|
|
|
|
generateHighestOutcomes(count, sides, []int{}, outcomes)
|
|
|
|
|
} else if modifier == "L" {
|
|
|
|
|
// Keep only the lowest die
|
|
|
|
|
generateLowestOutcomes(count, sides, []int{}, outcomes)
|
|
|
|
|
} else {
|
|
|
|
|
// Sum all dice
|
|
|
|
|
generateSumOutcomes(count, sides, []int{}, outcomes)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return outcomes
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// generateSumOutcomes recursively generates all sums
|
|
|
|
|
func generateSumOutcomes(remaining int, sides int, current []int, outcomes map[int]int) {
|
|
|
|
|
if remaining == 0 {
|
|
|
|
|
sum := 0
|
|
|
|
|
for _, val := range current {
|
|
|
|
|
sum += val
|
|
|
|
|
}
|
|
|
|
|
outcomes[sum]++
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for die := 1; die <= sides; die++ {
|
|
|
|
|
generateSumOutcomes(remaining-1, sides, append(current, die), outcomes)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// generateHighestOutcomes recursively generates all highest-die outcomes
|
|
|
|
|
func generateHighestOutcomes(remaining int, sides int, current []int, outcomes map[int]int) {
|
|
|
|
|
if remaining == 0 {
|
|
|
|
|
highest := 0
|
|
|
|
|
for _, val := range current {
|
|
|
|
|
if val > highest {
|
|
|
|
|
highest = val
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
outcomes[highest]++
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for die := 1; die <= sides; die++ {
|
|
|
|
|
generateHighestOutcomes(remaining-1, sides, append(current, die), outcomes)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// generateLowestOutcomes recursively generates all lowest-die outcomes
|
|
|
|
|
func generateLowestOutcomes(remaining int, sides int, current []int, outcomes map[int]int) {
|
|
|
|
|
if remaining == 0 {
|
|
|
|
|
lowest := sides + 1
|
|
|
|
|
for _, val := range current {
|
|
|
|
|
if val < lowest {
|
|
|
|
|
lowest = val
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
outcomes[lowest]++
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for die := 1; die <= sides; die++ {
|
|
|
|
|
generateLowestOutcomes(remaining-1, sides, append(current, die), outcomes)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetSortedOutcomes returns sorted unique outcomes
|
|
|
|
|
func (s *DiceStatistics) GetSortedOutcomes() []int {
|
|
|
|
|
var outcomes []int
|
|
|
|
|
for value := range s.Results {
|
|
|
|
|
outcomes = append(outcomes, value)
|
|
|
|
|
}
|
|
|
|
|
sort.Ints(outcomes)
|
|
|
|
|
return outcomes
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetMaxPercentage returns the maximum percentage value
|
|
|
|
|
func (s *DiceStatistics) GetMaxPercentage() float64 {
|
|
|
|
|
maxPercentage := 0.0
|
|
|
|
|
for _, percentage := range s.Percentages {
|
|
|
|
|
if percentage > maxPercentage {
|
|
|
|
|
maxPercentage = percentage
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return maxPercentage
|
|
|
|
|
}
|
2026-02-12 09:10:42 -06:00
|
|
|
|
|
|
|
|
// calculateAverageAndMedian calculates the average and most common value
|
|
|
|
|
func (s *DiceStatistics) calculateAverageAndMedian() {
|
|
|
|
|
if len(s.Results) == 0 {
|
|
|
|
|
s.Average = 0
|
|
|
|
|
s.MostCommon = 0
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate average (mean)
|
|
|
|
|
sum := 0
|
|
|
|
|
totalCount := 0
|
|
|
|
|
for value, count := range s.Results {
|
|
|
|
|
sum += value * count
|
|
|
|
|
totalCount += count
|
|
|
|
|
}
|
|
|
|
|
s.Average = float64(sum) / float64(totalCount)
|
|
|
|
|
|
|
|
|
|
// Find most common (mode) - the value with highest count
|
|
|
|
|
maxCount := 0
|
|
|
|
|
for value, count := range s.Results {
|
|
|
|
|
if count > maxCount {
|
|
|
|
|
maxCount = count
|
|
|
|
|
s.MostCommon = value
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If there are tied values, choose the smallest one
|
|
|
|
|
if maxCount > 0 {
|
|
|
|
|
for value, count := range s.Results {
|
|
|
|
|
if count == maxCount && value < s.MostCommon {
|
|
|
|
|
s.MostCommon = value
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|