Files
desktop_dice_statistics_cal…/statistics.go
T

633 lines
15 KiB
Go
Raw Normal View History

package main
import (
"fmt"
"math"
"math/big"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"sync"
)
// DiceStatistics holds the theoretical statistics for a dice roll.
type DiceStatistics struct {
MinValue int
MaxValue int
Results Distribution // outcome -> probability
TotalOutcomes *big.Int // exact number of equally likely underlying outcomes
TotalOutcomesText string // cached string form for UI
Percentages map[int]float64
SortedOutcomes []int
MaxPercentage float64
Average float64
MostCommon int
}
// Distribution represents the probability distribution of outcomes.
type Distribution map[int]float64
type distResult struct {
dist Distribution
totalOutcomes *big.Int
}
type distributionEntry struct {
value int
weight float64
}
const (
combineParallelThreshold = 4096
convolutionParallelThreshold = 4096
probabilityEpsilon = 1e-12
)
// Regex patterns for parsing
var (
diceTokenPattern = regexp.MustCompile(`^([HL])?(\d*)d(\d+)([HL])?`)
numberTokenPattern = regexp.MustCompile(`^(\d+(\.\d+)?)`)
)
// 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")
}
parser := &statParser{expr: expression, pos: 0}
result, err := parser.parseExpression()
if err != nil {
return nil, err
}
parser.skipWhitespace()
if parser.pos < len(parser.expr) {
return nil, fmt.Errorf("unexpected character at position %d: '%c'", parser.pos, parser.expr[parser.pos])
}
if len(result.dist) == 0 {
return nil, fmt.Errorf("no valid outcomes for expression")
}
2026-02-12 09:10:42 -06:00
stats := &DiceStatistics{
Results: normalizeDistribution(result.dist),
TotalOutcomes: cloneBigInt(result.totalOutcomes),
TotalOutcomesText: cloneBigInt(result.totalOutcomes).String(),
Percentages: make(map[int]float64, len(result.dist)),
2026-02-12 09:10:42 -06:00
}
stats.populateDerivedFields()
2026-02-12 09:10:42 -06:00
return stats, nil
}
// statParser implementation
type statParser struct {
expr string
pos int
}
func (p *statParser) skipWhitespace() {
for p.pos < len(p.expr) && (p.expr[p.pos] == ' ' || p.expr[p.pos] == '\t') {
p.pos++
}
}
// parseExpression handles addition and subtraction.
func (p *statParser) parseExpression() (distResult, error) {
left, err := p.parseTerm()
if err != nil {
return distResult{}, err
}
for {
p.skipWhitespace()
if p.pos >= len(p.expr) {
break
}
if p.expr[p.pos] == '+' {
p.pos++
right, err := p.parseTerm()
if err != nil {
return distResult{}, err
}
left = combineIndependent(left, right, func(a, b int) int { return a + b })
} else if p.expr[p.pos] == '-' {
p.pos++
right, err := p.parseTerm()
if err != nil {
return distResult{}, err
}
left = combineIndependent(left, right, func(a, b int) int { return a - b })
} else {
break
}
}
return left, nil
}
// parseTerm handles multiplication, division and implicit multiplication.
func (p *statParser) parseTerm() (distResult, error) {
left, err := p.parsePower()
if err != nil {
return distResult{}, 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 distResult{}, err
}
left = combineIndependent(left, right, func(a, b int) int { return a * b })
} else if c == '/' {
p.pos++
right, err := p.parsePower()
if err != nil {
return distResult{}, err
}
left = combineIndependentFiltered(left, right, divideValues)
} else if c == '(' || (c >= '0' && c <= '9') || c == 'd' || c == 'H' || c == 'L' {
right, err := p.parsePower()
if err != nil {
return distResult{}, err
}
left = combineIndependent(left, right, func(a, b int) int { return a * b })
} else {
break
}
}
return left, nil
}
// parsePower handles exponentiation.
func (p *statParser) parsePower() (distResult, error) {
left, err := p.parseFactor()
if err != nil {
return distResult{}, 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 distResult{}, err
}
left = combineIndependent(left, right, powerValues)
} else {
break
}
}
return left, nil
}
// parseFactor handles parentheses, dice, and numbers.
func (p *statParser) parseFactor() (distResult, error) {
p.skipWhitespace()
if p.pos >= len(p.expr) {
return distResult{}, fmt.Errorf("unexpected end of expression")
}
if p.expr[p.pos] == '(' {
p.pos++
dist, err := p.parseExpression()
if err != nil {
return distResult{}, err
}
p.skipWhitespace()
if p.pos >= len(p.expr) || p.expr[p.pos] != ')' {
return distResult{}, fmt.Errorf("missing closing parenthesis")
}
p.pos++
return dist, nil
}
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)
}
if loc := numberTokenPattern.FindStringIndex(remaining); loc != nil {
token := remaining[loc[0]:loc[1]]
p.pos += loc[1]
valFloat, err := strconv.ParseFloat(token, 64)
if err != nil {
return distResult{}, fmt.Errorf("invalid number: %s", token)
}
return newDistResult(Distribution{int(valFloat): 1}), nil
}
return distResult{}, fmt.Errorf("unexpected character: %c", p.expr[p.pos])
}
func parseDiceToken(token string) (distResult, error) {
matches := diceTokenPattern.FindStringSubmatch(token)
if matches == nil {
return distResult{}, fmt.Errorf("invalid dice term: %s", token)
}
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 distResult{}, err
}
count = c
}
sides, err := strconv.Atoi(sidesStr)
if err != nil {
return distResult{}, err
}
if count <= 0 || sides <= 0 {
return distResult{}, fmt.Errorf("dice terms must use positive counts and sides")
}
modifier := ""
if suffixModifier != "" {
modifier = suffixModifier
} else if prefixModifier != "" {
modifier = prefixModifier
}
return getDiceOutcomes(count, sides, modifier), nil
}
func newDistResult(dist Distribution) distResult {
return distResult{
dist: normalizeDistribution(dist),
totalOutcomes: big.NewInt(1),
}
}
func cloneBigInt(v *big.Int) *big.Int {
if v == nil {
return big.NewInt(0)
}
return new(big.Int).Set(v)
}
func multiplyOutcomeCounts(a, b *big.Int) *big.Int {
return new(big.Int).Mul(cloneBigInt(a), cloneBigInt(b))
}
func normalizeDistribution(dist Distribution) Distribution {
total := 0.0
for _, weight := range dist {
total += weight
}
if total == 0 {
return dist
}
if math.Abs(total-1) <= probabilityEpsilon {
pruned := make(Distribution, len(dist))
for value, weight := range dist {
if weight != 0 {
pruned[value] = weight
}
}
return pruned
}
normalized := make(Distribution, len(dist))
for value, weight := range dist {
probability := weight / total
if probability != 0 {
normalized[value] = probability
}
}
return normalized
}
func combineIndependent(a, b distResult, op func(int, int) int) distResult {
return distResult{
dist: combineDistributions(a.dist, b.dist, op),
totalOutcomes: multiplyOutcomeCounts(a.totalOutcomes, b.totalOutcomes),
}
}
func combineIndependentFiltered(a, b distResult, op func(int, int) (int, bool)) distResult {
return distResult{
dist: combineDistributionsFiltered(a.dist, b.dist, op),
totalOutcomes: multiplyOutcomeCounts(a.totalOutcomes, b.totalOutcomes),
}
}
func combineDistributions(a, b Distribution, op func(int, int) int) Distribution {
if len(a) == 0 || len(b) == 0 {
return Distribution{}
}
entriesA := make([]distributionEntry, 0, len(a))
for value, weight := range a {
entriesA = append(entriesA, distributionEntry{value: value, weight: weight})
}
entriesB := make([]distributionEntry, 0, len(b))
for value, weight := range b {
entriesB = append(entriesB, distributionEntry{value: value, weight: weight})
}
workSize := len(entriesA) * len(entriesB)
if workSize < combineParallelThreshold || len(entriesA) < 2 {
return combineDistributionsSerial(entriesA, entriesB, op)
}
workerCount := runtime.GOMAXPROCS(0)
if workerCount > len(entriesA) {
workerCount = len(entriesA)
}
if workerCount < 2 {
return combineDistributionsSerial(entriesA, entriesB, op)
}
chunkSize := (len(entriesA) + workerCount - 1) / workerCount
partials := make([]Distribution, workerCount)
var wg sync.WaitGroup
for worker := 0; worker < workerCount; worker++ {
start := worker * chunkSize
if start >= len(entriesA) {
break
}
end := start + chunkSize
if end > len(entriesA) {
end = len(entriesA)
}
wg.Add(1)
go func(workerIndex, from, to int) {
defer wg.Done()
local := make(Distribution)
for _, left := range entriesA[from:to] {
for _, right := range entriesB {
local[op(left.value, right.value)] += left.weight * right.weight
}
}
partials[workerIndex] = local
}(worker, start, end)
}
wg.Wait()
result := make(Distribution)
for _, partial := range partials {
for value, weight := range partial {
result[value] += weight
}
}
return normalizeDistribution(result)
}
func combineDistributionsFiltered(a, b Distribution, op func(int, int) (int, bool)) Distribution {
if len(a) == 0 || len(b) == 0 {
return Distribution{}
}
result := make(Distribution)
for leftValue, leftWeight := range a {
for rightValue, rightWeight := range b {
value, ok := op(leftValue, rightValue)
if !ok {
continue
}
result[value] += leftWeight * rightWeight
}
}
return normalizeDistribution(result)
}
func combineDistributionsSerial(entriesA, entriesB []distributionEntry, op func(int, int) int) Distribution {
result := make(Distribution)
for _, left := range entriesA {
for _, right := range entriesB {
result[op(left.value, right.value)] += left.weight * right.weight
}
}
return normalizeDistribution(result)
}
func divideValues(a, b int) (int, bool) {
if b == 0 {
return 0, false
}
return a / b, true
}
func powerValues(a, b int) int {
return int(math.Pow(float64(a), float64(b)))
}
// getDiceOutcomes returns a probability distribution for a dice roll and the exact total outcome count.
func getDiceOutcomes(count int, sides int, modifier string) distResult {
totalOutcomes := new(big.Int).Exp(big.NewInt(int64(sides)), big.NewInt(int64(count)), nil)
var outcomes Distribution
switch modifier {
case "H":
outcomes = highestDieDistribution(count, sides)
case "L":
outcomes = lowestDieDistribution(count, sides)
default:
outcomes = sumDiceDistribution(count, sides)
}
return distResult{
dist: outcomes,
totalOutcomes: totalOutcomes,
}
}
func sumDiceDistribution(count int, sides int) Distribution {
current := []float64{1}
singleDieProbability := 1 / float64(sides)
for die := 0; die < count; die++ {
next := make([]float64, len(current)+sides)
if len(current)*sides >= convolutionParallelThreshold {
convolveStepParallel(current, next, sides, singleDieProbability)
} else {
convolveStepSerial(current, next, sides, singleDieProbability)
}
current = next
}
outcomes := make(Distribution, count*(sides-1)+1)
for sum, probability := range current {
if probability != 0 {
outcomes[sum] = probability
}
}
return normalizeDistribution(outcomes)
}
func convolveStepSerial(current, next []float64, sides int, singleDieProbability float64) {
for sum, probability := range current {
if probability == 0 {
continue
}
for face := 1; face <= sides; face++ {
next[sum+face] += probability * singleDieProbability
}
}
}
func convolveStepParallel(current, next []float64, sides int, singleDieProbability float64) {
workerCount := runtime.GOMAXPROCS(0)
if workerCount > len(current) {
workerCount = len(current)
}
if workerCount < 2 {
convolveStepSerial(current, next, sides, singleDieProbability)
return
}
chunkSize := (len(current) + workerCount - 1) / workerCount
partials := make([][]float64, workerCount)
var wg sync.WaitGroup
for worker := 0; worker < workerCount; worker++ {
start := worker * chunkSize
if start >= len(current) {
break
}
end := start + chunkSize
if end > len(current) {
end = len(current)
}
wg.Add(1)
go func(workerIndex, from, to int) {
defer wg.Done()
local := make([]float64, len(next))
for sum := from; sum < to; sum++ {
probability := current[sum]
if probability == 0 {
continue
}
for face := 1; face <= sides; face++ {
local[sum+face] += probability * singleDieProbability
}
}
partials[workerIndex] = local
}(worker, start, end)
}
wg.Wait()
for _, partial := range partials {
if partial == nil {
continue
}
for i, probability := range partial {
next[i] += probability
}
}
}
2026-02-12 09:10:42 -06:00
func highestDieDistribution(count int, sides int) Distribution {
outcomes := make(Distribution, sides)
denominator := float64(sides)
for value := 1; value <= sides; value++ {
current := math.Pow(float64(value)/denominator, float64(count))
previous := math.Pow(float64(value-1)/denominator, float64(count))
probability := current - previous
if probability != 0 {
outcomes[value] = probability
}
}
return normalizeDistribution(outcomes)
}
func lowestDieDistribution(count int, sides int) Distribution {
outcomes := make(Distribution, sides)
denominator := float64(sides)
for value := 1; value <= sides; value++ {
current := math.Pow(float64(sides-value+1)/denominator, float64(count))
next := math.Pow(float64(sides-value)/denominator, float64(count))
probability := current - next
if probability != 0 {
outcomes[value] = probability
}
}
return normalizeDistribution(outcomes)
}
func (s *DiceStatistics) populateDerivedFields() {
2026-02-12 09:10:42 -06:00
if len(s.Results) == 0 {
s.TotalOutcomesText = cloneBigInt(s.TotalOutcomes).String()
2026-02-12 09:10:42 -06:00
return
}
s.SortedOutcomes = make([]int, 0, len(s.Results))
s.Percentages = make(map[int]float64, len(s.Results))
2026-02-12 09:10:42 -06:00
first := true
maxProbability := 0.0
for value, probability := range s.Results {
s.SortedOutcomes = append(s.SortedOutcomes, value)
s.Percentages[value] = probability * 100
if first {
s.MinValue = value
s.MaxValue = value
s.MostCommon = value
first = false
} else {
if value < s.MinValue {
s.MinValue = value
}
if value > s.MaxValue {
s.MaxValue = value
}
}
s.Average += float64(value) * probability
if probability > maxProbability || (math.Abs(probability-maxProbability) <= probabilityEpsilon && value < s.MostCommon) {
maxProbability = probability
2026-02-12 09:10:42 -06:00
s.MostCommon = value
}
}
sort.Ints(s.SortedOutcomes)
s.MaxPercentage = maxProbability * 100
}
// GetSortedOutcomes returns cached sorted unique outcomes.
func (s *DiceStatistics) GetSortedOutcomes() []int {
return s.SortedOutcomes
}
// GetMaxPercentage returns the cached maximum percentage value.
func (s *DiceStatistics) GetMaxPercentage() float64 {
return s.MaxPercentage
2026-02-12 09:10:42 -06:00
}