fixed issue with expoentially more time for large dice rolls, and also fixed an integer overflow bug

This commit is contained in:
grimsace
2026-03-31 11:18:19 -05:00
parent 1d7a068752
commit ac0866e4a9
2 changed files with 615 additions and 389 deletions
+198 -117
View File
@@ -11,34 +11,77 @@ import (
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
) )
// barGraphCanvas is a custom widget that renders a bar graph // barGraphCanvas is a custom widget that renders a bar graph.
type barGraphCanvas struct { type barGraphCanvas struct {
widget.BaseWidget widget.BaseWidget
stats *DiceStatistics stats *DiceStatistics
} }
func newBarGraphCanvas(stats *DiceStatistics) *barGraphCanvas { func newBarGraphCanvas(stats *DiceStatistics) *barGraphCanvas {
graph := &barGraphCanvas{ graph := &barGraphCanvas{stats: stats}
stats: stats,
}
graph.ExtendBaseWidget(graph) graph.ExtendBaseWidget(graph)
return graph return graph
} }
func (b *barGraphCanvas) CreateRenderer() fyne.WidgetRenderer { func (b *barGraphCanvas) CreateRenderer() fyne.WidgetRenderer {
b.ExtendBaseWidget(b) b.ExtendBaseWidget(b)
return &barGraphCanvasRenderer{
background := canvas.NewRectangle(color.NRGBA{R: 20, G: 20, B: 20, A: 255})
yAxisLine := canvas.NewLine(color.White)
yAxisLine.StrokeWidth = 2
xAxisLine := canvas.NewLine(color.White)
xAxisLine.StrokeWidth = 2
title := canvas.NewText("Probability Distribution", color.White)
statsLine1 := canvas.NewText("", color.White)
statsLine2 := canvas.NewText("", color.White)
yLabel := canvas.NewText("Probability (%)", color.White)
xLabel := canvas.NewText("Result Value", color.White)
renderer := &barGraphCanvasRenderer{
graph: b, graph: b,
background: background,
yAxisLine: yAxisLine,
xAxisLine: xAxisLine,
title: title,
statsLine1: statsLine1,
statsLine2: statsLine2,
yLabel: yLabel,
xLabel: xLabel,
objects: []fyne.CanvasObject{
background,
yAxisLine,
xAxisLine,
title,
statsLine1,
statsLine2,
yLabel,
xLabel,
},
} }
renderer.Refresh()
return renderer
} }
type barGraphCanvasRenderer struct { type barGraphCanvasRenderer struct {
graph *barGraphCanvas graph *barGraphCanvas
background *canvas.Rectangle
yAxisLine *canvas.Line
xAxisLine *canvas.Line
title *canvas.Text
statsLine1 *canvas.Text
statsLine2 *canvas.Text
yLabel *canvas.Text
xLabel *canvas.Text
yTicks []*canvas.Line
yTickLabels []*canvas.Text
bars []*canvas.Rectangle
barLabels []*canvas.Text
objects []fyne.CanvasObject objects []fyne.CanvasObject
} }
func (r *barGraphCanvasRenderer) Layout(size fyne.Size) { func (r *barGraphCanvasRenderer) Layout(size fyne.Size) {
r.Refresh() r.layout(size)
} }
func (r *barGraphCanvasRenderer) MinSize() fyne.Size { func (r *barGraphCanvasRenderer) MinSize() fyne.Size {
@@ -46,24 +89,75 @@ func (r *barGraphCanvasRenderer) MinSize() fyne.Size {
} }
func (r *barGraphCanvasRenderer) Refresh() { func (r *barGraphCanvasRenderer) Refresh() {
r.objects = []fyne.CanvasObject{} stats := r.graph.stats
if stats == nil || len(stats.Results) == 0 {
if r.graph.stats == nil || len(r.graph.stats.Results) == 0 { r.hideAllButBackground()
r.background.Show()
r.background.Refresh()
return return
} }
stats := r.graph.stats r.background.Show()
outcomes := stats.GetSortedOutcomes() r.syncData(stats)
maxPercentage := stats.GetMaxPercentage()
axisMaxPercent, tickStep := calculateYAxisScale(maxPercentage)
size := r.graph.Size() size := r.graph.Size()
if size.Width == 0 || size.Height == 0 { if size.Width == 0 || size.Height == 0 {
size = fyne.NewSize(900, 550) size = fyne.NewSize(900, 550)
} }
r.layout(size)
}
func (r *barGraphCanvasRenderer) syncData(stats *DiceStatistics) {
r.title.Show()
r.statsLine1.Show()
r.statsLine2.Show()
r.yLabel.Show()
r.xLabel.Show()
r.yAxisLine.Show()
r.xAxisLine.Show()
r.statsLine1.Text = fmt.Sprintf("Range: %d to %d | Total Outcomes: %s", stats.MinValue, stats.MaxValue, stats.TotalOutcomesText)
r.statsLine2.Text = fmt.Sprintf("Average: %.2f | Most Common: %d", stats.Average, stats.MostCommon)
axisMaxPercent, tickStep := calculateYAxisScale(stats.GetMaxPercentage())
numYTicks := int(math.Round(axisMaxPercent / tickStep))
r.ensureYTicks(numYTicks + 1)
for i := 0; i <= numYTicks; i++ {
percent := float64(i) * tickStep
r.yTicks[i].Show()
r.yTickLabels[i].Text = formatPercentLabel(percent)
r.yTickLabels[i].Show()
}
for i := numYTicks + 1; i < len(r.yTicks); i++ {
r.yTicks[i].Hide()
r.yTickLabels[i].Hide()
}
outcomes := stats.GetSortedOutcomes()
r.ensureBars(len(outcomes))
for i := range outcomes {
r.bars[i].Show()
r.barLabels[i].Hide()
}
for i := len(outcomes); i < len(r.bars); i++ {
r.bars[i].Hide()
r.barLabels[i].Hide()
}
r.refreshObjects()
}
func (r *barGraphCanvasRenderer) layout(size fyne.Size) {
r.background.Move(fyne.NewPos(0, 0))
r.background.Resize(size)
stats := r.graph.stats
if stats == nil || len(stats.Results) == 0 {
return
}
axisMaxPercent, tickStep := calculateYAxisScale(stats.GetMaxPercentage())
outcomes := stats.GetSortedOutcomes()
// Padding and typography scale with the available area so the graph fits the window.
topPadding := clamp(size.Height*0.14, 48, 75) topPadding := clamp(size.Height*0.14, 48, 75)
bottomPadding := clamp(size.Height*0.15, 56, 80) bottomPadding := clamp(size.Height*0.15, 56, 80)
leftPadding := clamp(size.Width*0.12, 60, 100) leftPadding := clamp(size.Width*0.12, 60, 100)
@@ -80,79 +174,43 @@ func (r *barGraphCanvasRenderer) Refresh() {
labelTextSize := clamp(size.Height*0.022, 10, 12) labelTextSize := clamp(size.Height*0.022, 10, 12)
tickTextSize := clamp(size.Height*0.018, 8, 10) tickTextSize := clamp(size.Height*0.018, 8, 10)
// Background r.yAxisLine.Move(fyne.NewPos(leftPadding, topPadding))
background := canvas.NewRectangle(color.NRGBA{R: 20, G: 20, B: 20, A: 255}) r.yAxisLine.Resize(fyne.NewSize(0, graphHeight))
background.Move(fyne.NewPos(0, 0)) r.xAxisLine.Move(fyne.NewPos(leftPadding, topPadding+graphHeight))
background.Resize(size) r.xAxisLine.Resize(fyne.NewSize(graphWidth, 0))
r.objects = append(r.objects, background)
// Y-axis r.title.TextSize = titleSize
yAxisLine := canvas.NewLine(color.White) r.title.Move(fyne.NewPos(leftPadding, 5))
yAxisLine.StrokeWidth = 2
yAxisLine.Move(fyne.NewPos(leftPadding, topPadding))
yAxisLine.Resize(fyne.NewSize(0, graphHeight))
r.objects = append(r.objects, yAxisLine)
// X-axis r.statsLine1.TextSize = bodyTextSize
xAxisLine := canvas.NewLine(color.White) r.statsLine1.Move(fyne.NewPos(leftPadding, 5+r.title.MinSize().Height))
xAxisLine.StrokeWidth = 2
xAxisLine.Move(fyne.NewPos(leftPadding, topPadding+graphHeight))
xAxisLine.Resize(fyne.NewSize(graphWidth, 0))
r.objects = append(r.objects, xAxisLine)
// Title r.statsLine2.TextSize = bodyTextSize
title := canvas.NewText("Probability Distribution", color.White) r.statsLine2.Move(fyne.NewPos(leftPadding, 5+r.title.MinSize().Height+r.statsLine1.MinSize().Height))
title.TextSize = titleSize
title.Move(fyne.NewPos(leftPadding, 5))
r.objects = append(r.objects, title)
// Statistics info line 1 r.yLabel.TextSize = labelTextSize
statsLine1 := canvas.NewText(fmt.Sprintf("Range: %d to %d | Total Outcomes: %d", stats.MinValue, stats.MaxValue, stats.Total), color.White) r.yLabel.Move(fyne.NewPos(clamp(leftPadding*0.15, 8, 15), topPadding+graphHeight/2-r.yLabel.MinSize().Height/2))
statsLine1.TextSize = bodyTextSize
statsLine1.Move(fyne.NewPos(leftPadding, 5+title.MinSize().Height))
r.objects = append(r.objects, statsLine1)
// Statistics info line 2 r.xLabel.TextSize = labelTextSize
statsLine2 := canvas.NewText(fmt.Sprintf("Average: %.2f | Most Common: %d", stats.Average, stats.MostCommon), color.White) r.xLabel.Move(fyne.NewPos(leftPadding+graphWidth/2-r.xLabel.MinSize().Width/2, topPadding+graphHeight+clamp(bottomPadding*0.35, 18, 28)))
statsLine2.TextSize = bodyTextSize
statsLine2.Move(fyne.NewPos(leftPadding, 5+title.MinSize().Height+statsLine1.MinSize().Height))
r.objects = append(r.objects, statsLine2)
// Y-axis label
yLabel := canvas.NewText("Probability (%)", color.White)
yLabel.TextSize = labelTextSize
yLabel.Move(fyne.NewPos(clamp(leftPadding*0.15, 8, 15), topPadding+graphHeight/2-yLabel.MinSize().Height/2))
r.objects = append(r.objects, yLabel)
// X-axis label
xLabel := canvas.NewText("Result Value", color.White)
xLabel.TextSize = labelTextSize
xLabel.Move(fyne.NewPos(leftPadding+graphWidth/2-xLabel.MinSize().Width/2, topPadding+graphHeight+clamp(bottomPadding*0.35, 18, 28)))
r.objects = append(r.objects, xLabel)
// Y-axis tick marks and labels
numYTicks := int(math.Round(axisMaxPercent / tickStep)) numYTicks := int(math.Round(axisMaxPercent / tickStep))
for i := 0; i <= numYTicks; i++ { for i := 0; i <= numYTicks && i < len(r.yTicks); i++ {
percent := float64(i) * tickStep percent := float64(i) * tickStep
yPos := topPadding + graphHeight - (float32(percent/axisMaxPercent) * graphHeight) yPos := topPadding + graphHeight - (float32(percent/axisMaxPercent) * graphHeight)
// Tick mark r.yTicks[i].Move(fyne.NewPos(leftPadding-5, yPos))
tick := canvas.NewLine(color.White) r.yTicks[i].Resize(fyne.NewSize(5, 0))
tick.StrokeWidth = 1 r.yTickLabels[i].TextSize = tickTextSize
tick.Move(fyne.NewPos(leftPadding-5, yPos)) r.yTickLabels[i].Move(fyne.NewPos(leftPadding-50, yPos-7))
tick.Resize(fyne.NewSize(5, 0))
r.objects = append(r.objects, tick)
// Label
label := canvas.NewText(formatPercentLabel(percent), color.White)
label.TextSize = tickTextSize
label.Move(fyne.NewPos(leftPadding-50, yPos-7))
r.objects = append(r.objects, label)
} }
// Draw bars
numBars := len(outcomes) numBars := len(outcomes)
if numBars == 0 {
r.refreshObjects()
return
}
barSpacing := float32(2) barSpacing := float32(2)
totalSpacing := float32(numBars+1) * barSpacing totalSpacing := float32(numBars+1) * barSpacing
barWidth := (graphWidth - totalSpacing) / float32(numBars) barWidth := (graphWidth - totalSpacing) / float32(numBars)
@@ -160,45 +218,77 @@ func (r *barGraphCanvasRenderer) Refresh() {
barWidth = 1 barWidth = 1
} }
// Calculate label step to prevent overlapping
labelStep := calculateLabelStep(graphWidth, numBars) labelStep := calculateLabelStep(graphWidth, numBars)
for i, value := range outcomes { for i, value := range outcomes {
percentage := stats.Percentages[value] percentage := stats.Percentages[value]
// Bar height proportional to percentage
barHeight := (float32(percentage) / float32(axisMaxPercent)) * graphHeight barHeight := (float32(percentage) / float32(axisMaxPercent)) * graphHeight
// X position
xPos := leftPadding + barSpacing + float32(i)*(barWidth+barSpacing) xPos := leftPadding + barSpacing + float32(i)*(barWidth+barSpacing)
// Draw bar r.bars[i].Move(fyne.NewPos(xPos, topPadding+graphHeight-barHeight))
r.bars[i].Resize(fyne.NewSize(barWidth, barHeight))
r.barLabels[i].TextSize = tickTextSize
r.barLabels[i].Alignment = fyne.TextAlignCenter
if i == 0 || i == numBars-1 || (labelStep > 0 && i%labelStep == 0 && i < numBars-labelStep) {
r.barLabels[i].Text = fmt.Sprintf("%d", value)
r.barLabels[i].Show()
r.barLabels[i].Move(fyne.NewPos(xPos+barWidth/2-r.barLabels[i].MinSize().Width/2, topPadding+graphHeight+10))
} else {
r.barLabels[i].Hide()
}
}
r.refreshObjects()
}
func (r *barGraphCanvasRenderer) ensureYTicks(count int) {
for len(r.yTicks) < count {
tick := canvas.NewLine(color.White)
tick.StrokeWidth = 1
label := canvas.NewText("", color.White)
r.yTicks = append(r.yTicks, tick)
r.yTickLabels = append(r.yTickLabels, label)
r.objects = append(r.objects, tick, label)
}
}
func (r *barGraphCanvasRenderer) ensureBars(count int) {
for len(r.bars) < count {
bar := canvas.NewRectangle(color.NRGBA{R: 100, G: 180, B: 255, A: 255}) bar := canvas.NewRectangle(color.NRGBA{R: 100, G: 180, B: 255, A: 255})
bar.Move(fyne.NewPos(xPos, topPadding+graphHeight-barHeight)) label := canvas.NewText("", color.White)
bar.Resize(fyne.NewSize(barWidth, barHeight)) r.bars = append(r.bars, bar)
r.objects = append(r.objects, bar) r.barLabels = append(r.barLabels, label)
r.objects = append(r.objects, bar, label)
// X-axis label
// Always show first and last label
isFirst := i == 0
isLast := i == numBars-1
// Determine if we should show this intermediate label
// We show it if it matches the step, BUT we also need to make sure it doesn't clash with the last label
// So if we are very close to the end, don't show it (unless it IS the end)
showIntermediate := i%labelStep == 0 && i < numBars-labelStep
if isFirst || isLast || showIntermediate {
label := canvas.NewText(fmt.Sprintf("%d", value), color.White)
label.TextSize = tickTextSize
// Center label under bar
label.Alignment = fyne.TextAlignCenter
label.Move(fyne.NewPos(xPos+barWidth/2-label.MinSize().Width/2, topPadding+graphHeight+10))
r.objects = append(r.objects, label)
} }
}
func (r *barGraphCanvasRenderer) availableGraphWidth() float32 {
size := r.graph.Size()
if size.Width == 0 {
size.Width = 900
} }
leftPadding := clamp(size.Width*0.12, 60, 100)
rightPadding := clamp(size.Width*0.03, 16, 24)
return size.Width - leftPadding - rightPadding
}
func (r *barGraphCanvasRenderer) hideAllButBackground() {
for _, object := range r.objects {
object.Hide()
}
}
func (r *barGraphCanvasRenderer) refreshObjects() {
for _, object := range r.objects {
object.Refresh()
}
}
func (r *barGraphCanvasRenderer) Objects() []fyne.CanvasObject {
return r.objects
}
func (r *barGraphCanvasRenderer) Destroy() {
} }
func clamp(value, minValue, maxValue float32) float32 { func clamp(value, minValue, maxValue float32) float32 {
@@ -212,7 +302,7 @@ func clamp(value, minValue, maxValue float32) float32 {
} }
func calculateLabelStep(graphWidth float32, numBars int) int { func calculateLabelStep(graphWidth float32, numBars int) int {
labelWidthEstimate := float32(35) // Estimate width of a label labelWidthEstimate := float32(35)
maxLabels := int(graphWidth / labelWidthEstimate) maxLabels := int(graphWidth / labelWidthEstimate)
if maxLabels < 1 { if maxLabels < 1 {
maxLabels = 1 maxLabels = 1
@@ -258,14 +348,7 @@ func formatPercentLabel(percent float64) string {
return fmt.Sprintf("%.1f%%", percent) return fmt.Sprintf("%.1f%%", percent)
} }
func (r *barGraphCanvasRenderer) Objects() []fyne.CanvasObject { // ShowStatisticsWindow creates and shows a statistics window for the given expression.
return r.objects
}
func (r *barGraphCanvasRenderer) Destroy() {
}
// ShowStatisticsWindow creates and shows a statistics window for the given expression
func ShowStatisticsWindow(expression string) { func ShowStatisticsWindow(expression string) {
stats, err := CalculateDiceStatistics(expression) stats, err := CalculateDiceStatistics(expression)
if err != nil { if err != nil {
@@ -273,10 +356,8 @@ func ShowStatisticsWindow(expression string) {
return return
} }
// Create the bar graph
graph := newBarGraphCanvas(stats) graph := newBarGraphCanvas(stats)
// Create and show the window
window := fyne.CurrentApp().NewWindow("Statistics: " + expression) window := fyne.CurrentApp().NewWindow("Statistics: " + expression)
window.SetContent(container.NewMax(graph)) window.SetContent(container.NewMax(graph))
window.Resize(fyne.NewSize(900, 550)) window.Resize(fyne.NewSize(900, 550))
+379 -234
View File
@@ -3,34 +3,55 @@ package main
import ( import (
"fmt" "fmt"
"math" "math"
"math/big"
"regexp" "regexp"
"runtime"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"sync"
) )
// DiceStatistics holds the theoretical statistics for a dice roll // DiceStatistics holds the theoretical statistics for a dice roll.
type DiceStatistics struct { type DiceStatistics struct {
MinValue int MinValue int
MaxValue int MaxValue int
Results map[int]int // outcome -> count of ways to achieve it Results Distribution // outcome -> probability
Total int // total number of possible outcomes TotalOutcomes *big.Int // exact number of equally likely underlying outcomes
Percentages map[int]float64 // outcome -> percentage TotalOutcomesText string // cached string form for UI
Average float64 // average/mean value Percentages map[int]float64
MostCommon int // most common (median) value SortedOutcomes []int
MaxPercentage float64
Average float64
MostCommon int
} }
// Distribution represents the frequency distribution of outcomes // Distribution represents the probability distribution of outcomes.
type Distribution map[int]int 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 // Regex patterns for parsing
var ( var (
diceTokenPattern = regexp.MustCompile(`^([HL])?(\d*)d(\d+)([HL])?`) diceTokenPattern = regexp.MustCompile(`^([HL])?(\d*)d(\d+)([HL])?`)
// Updated numberTokenPattern to include optional decimal part
numberTokenPattern = regexp.MustCompile(`^(\d+(\.\d+)?)`) 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)
if expression == "" { if expression == "" {
@@ -38,7 +59,7 @@ func CalculateDiceStatistics(expression string) (*DiceStatistics, error) {
} }
parser := &statParser{expr: expression, pos: 0} parser := &statParser{expr: expression, pos: 0}
outcomes, err := parser.parseExpression() result, err := parser.parseExpression()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -48,49 +69,18 @@ func CalculateDiceStatistics(expression string) (*DiceStatistics, error) {
return nil, fmt.Errorf("unexpected character at position %d: '%c'", parser.pos, parser.expr[parser.pos]) return nil, fmt.Errorf("unexpected character at position %d: '%c'", parser.pos, parser.expr[parser.pos])
} }
if len(outcomes) == 0 { if len(result.dist) == 0 {
return nil, fmt.Errorf("no valid outcomes for expression") return nil, fmt.Errorf("no valid outcomes for expression")
} }
// Find min and max
minVal := 0
maxVal := 0
first := true
totalCount := 0
for value, count := range outcomes {
totalCount += count
if first {
minVal = value
maxVal = value
first = false
} else {
if value < minVal {
minVal = value
}
if value > maxVal {
maxVal = value
}
}
}
// Calculate percentages
percentages := make(map[int]float64)
for value, count := range outcomes {
percentages[value] = (float64(count) / float64(totalCount)) * 100
}
stats := &DiceStatistics{ stats := &DiceStatistics{
MinValue: minVal, Results: normalizeDistribution(result.dist),
MaxValue: maxVal, TotalOutcomes: cloneBigInt(result.totalOutcomes),
Results: outcomes, TotalOutcomesText: cloneBigInt(result.totalOutcomes).String(),
Total: totalCount, Percentages: make(map[int]float64, len(result.dist)),
Percentages: percentages,
} }
// Calculate average and most common value stats.populateDerivedFields()
stats.calculateAverageAndMedian()
return stats, nil return stats, nil
} }
@@ -106,11 +96,11 @@ func (p *statParser) skipWhitespace() {
} }
} }
// parseExpression handles addition and subtraction // parseExpression handles addition and subtraction.
func (p *statParser) parseExpression() (Distribution, error) { func (p *statParser) parseExpression() (distResult, error) {
left, err := p.parseTerm() left, err := p.parseTerm()
if err != nil { if err != nil {
return nil, err return distResult{}, err
} }
for { for {
@@ -123,16 +113,16 @@ func (p *statParser) parseExpression() (Distribution, error) {
p.pos++ p.pos++
right, err := p.parseTerm() right, err := p.parseTerm()
if err != nil { if err != nil {
return nil, err return distResult{}, err
} }
left = addDist(left, right) left = combineIndependent(left, right, func(a, b int) int { return a + b })
} else if p.expr[p.pos] == '-' { } else if p.expr[p.pos] == '-' {
p.pos++ p.pos++
right, err := p.parseTerm() right, err := p.parseTerm()
if err != nil { if err != nil {
return nil, err return distResult{}, err
} }
left = subDist(left, right) left = combineIndependent(left, right, func(a, b int) int { return a - b })
} else { } else {
break break
} }
@@ -141,11 +131,11 @@ func (p *statParser) parseExpression() (Distribution, error) {
return left, nil return left, nil
} }
// parseTerm handles multiplication, division and implicit multiplication // parseTerm handles multiplication, division and implicit multiplication.
func (p *statParser) parseTerm() (Distribution, error) { func (p *statParser) parseTerm() (distResult, error) {
left, err := p.parsePower() left, err := p.parsePower()
if err != nil { if err != nil {
return nil, err return distResult{}, err
} }
for { for {
@@ -159,23 +149,22 @@ func (p *statParser) parseTerm() (Distribution, error) {
p.pos++ p.pos++
right, err := p.parsePower() right, err := p.parsePower()
if err != nil { if err != nil {
return nil, err return distResult{}, err
} }
left = multDist(left, right) left = combineIndependent(left, right, func(a, b int) int { return a * b })
} else if c == '/' { } else if c == '/' {
p.pos++ p.pos++
right, err := p.parsePower() right, err := p.parsePower()
if err != nil { if err != nil {
return nil, err return distResult{}, err
} }
left = divDist(left, right) left = combineIndependentFiltered(left, right, divideValues)
} else if c == '(' || (c >= '0' && c <= '9') || c == 'd' || c == 'H' || c == 'L' { } 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() right, err := p.parsePower()
if err != nil { if err != nil {
return nil, err return distResult{}, err
} }
left = multDist(left, right) left = combineIndependent(left, right, func(a, b int) int { return a * b })
} else { } else {
break break
} }
@@ -184,11 +173,11 @@ func (p *statParser) parseTerm() (Distribution, error) {
return left, nil return left, nil
} }
// parsePower handles exponentiation // parsePower handles exponentiation.
func (p *statParser) parsePower() (Distribution, error) { func (p *statParser) parsePower() (distResult, error) {
left, err := p.parseFactor() left, err := p.parseFactor()
if err != nil { if err != nil {
return nil, err return distResult{}, err
} }
for { for {
@@ -201,9 +190,9 @@ func (p *statParser) parsePower() (Distribution, error) {
p.pos++ p.pos++
right, err := p.parseFactor() // Left-associative to match calculator right, err := p.parseFactor() // Left-associative to match calculator
if err != nil { if err != nil {
return nil, err return distResult{}, err
} }
left = powDist(left, right) left = combineIndependent(left, right, powerValues)
} else { } else {
break break
} }
@@ -212,29 +201,27 @@ func (p *statParser) parsePower() (Distribution, error) {
return left, nil return left, nil
} }
// parseFactor handles parentheses, dice, and numbers // parseFactor handles parentheses, dice, and numbers.
func (p *statParser) parseFactor() (Distribution, error) { func (p *statParser) parseFactor() (distResult, error) {
p.skipWhitespace() p.skipWhitespace()
if p.pos >= len(p.expr) { if p.pos >= len(p.expr) {
return nil, fmt.Errorf("unexpected end of expression") return distResult{}, fmt.Errorf("unexpected end of expression")
} }
// Parentheses
if p.expr[p.pos] == '(' { if p.expr[p.pos] == '(' {
p.pos++ p.pos++
dist, err := p.parseExpression() dist, err := p.parseExpression()
if err != nil { if err != nil {
return nil, err return distResult{}, err
} }
p.skipWhitespace() p.skipWhitespace()
if p.pos >= len(p.expr) || p.expr[p.pos] != ')' { if p.pos >= len(p.expr) || p.expr[p.pos] != ')' {
return nil, fmt.Errorf("missing closing parenthesis") return distResult{}, fmt.Errorf("missing closing parenthesis")
} }
p.pos++ p.pos++
return dist, nil return dist, nil
} }
// Try Dice Pattern
remaining := p.expr[p.pos:] remaining := p.expr[p.pos:]
if loc := diceTokenPattern.FindStringIndex(remaining); loc != nil { if loc := diceTokenPattern.FindStringIndex(remaining); loc != nil {
token := remaining[loc[0]:loc[1]] token := remaining[loc[0]:loc[1]]
@@ -242,26 +229,25 @@ func (p *statParser) parseFactor() (Distribution, error) {
return parseDiceToken(token) return parseDiceToken(token)
} }
// Try Number Pattern
if loc := numberTokenPattern.FindStringIndex(remaining); loc != nil { if loc := numberTokenPattern.FindStringIndex(remaining); loc != nil {
token := remaining[loc[0]:loc[1]] token := remaining[loc[0]:loc[1]]
p.pos += 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) valFloat, err := strconv.ParseFloat(token, 64)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid number: %s", token) return distResult{}, fmt.Errorf("invalid number: %s", token)
} }
return Distribution{int(valFloat): 1}, nil return newDistResult(Distribution{int(valFloat): 1}), nil
} }
return nil, fmt.Errorf("unexpected character: %c", p.expr[p.pos]) return distResult{}, fmt.Errorf("unexpected character: %c", p.expr[p.pos])
} }
func parseDiceToken(token string) (Distribution, error) { func parseDiceToken(token string) (distResult, error) {
matches := diceTokenPattern.FindStringSubmatch(token) matches := diceTokenPattern.FindStringSubmatch(token)
if matches == nil {
return distResult{}, fmt.Errorf("invalid dice term: %s", token)
}
if matches != nil {
// It is a dice expression
prefixModifier := matches[1] prefixModifier := matches[1]
countStr := matches[2] countStr := matches[2]
sidesStr := matches[3] sidesStr := matches[3]
@@ -271,14 +257,17 @@ func parseDiceToken(token string) (Distribution, error) {
if countStr != "" { if countStr != "" {
c, err := strconv.Atoi(countStr) c, err := strconv.Atoi(countStr)
if err != nil { if err != nil {
return nil, err return distResult{}, err
} }
count = c count = c
} }
sides, err := strconv.Atoi(sidesStr) sides, err := strconv.Atoi(sidesStr)
if err != nil { if err != nil {
return nil, err return distResult{}, err
}
if count <= 0 || sides <= 0 {
return distResult{}, fmt.Errorf("dice terms must use positive counts and sides")
} }
modifier := "" modifier := ""
@@ -289,199 +278,355 @@ func parseDiceToken(token string) (Distribution, error) {
} }
return getDiceOutcomes(count, sides, modifier), nil return getDiceOutcomes(count, sides, modifier), nil
}
return nil, fmt.Errorf("invalid dice term: %s", token)
} }
// Operations on Distributions func newDistResult(dist Distribution) distResult {
return distResult{
func addDist(a, b Distribution) Distribution { dist: normalizeDistribution(dist),
res := make(Distribution) totalOutcomes: big.NewInt(1),
for valA, countA := range a {
for valB, countB := range b {
res[valA+valB] += countA * countB
} }
}
return res
} }
func subDist(a, b Distribution) Distribution { func cloneBigInt(v *big.Int) *big.Int {
res := make(Distribution) if v == nil {
for valA, countA := range a { return big.NewInt(0)
for valB, countB := range b {
res[valA-valB] += countA * countB
} }
} return new(big.Int).Set(v)
return res
} }
func multDist(a, b Distribution) Distribution { func multiplyOutcomeCounts(a, b *big.Int) *big.Int {
res := make(Distribution) return new(big.Int).Mul(cloneBigInt(a), cloneBigInt(b))
for valA, countA := range a {
for valB, countB := range b {
res[valA*valB] += countA * countB
}
}
return res
} }
func divDist(a, b Distribution) Distribution { func normalizeDistribution(dist Distribution) Distribution {
res := make(Distribution) total := 0.0
for valA, countA := range a { for _, weight := range dist {
for valB, countB := range b { total += weight
if valB == 0 {
continue // Division by zero yields no outcome
} }
res[valA/valB] += countA * countB 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 res return pruned
}
normalized := make(Distribution, len(dist))
for value, weight := range dist {
probability := weight / total
if probability != 0 {
normalized[value] = probability
}
}
return normalized
} }
func powDist(a, b Distribution) Distribution { func combineIndependent(a, b distResult, op func(int, int) int) distResult {
res := make(Distribution) return distResult{
for valA, countA := range a { dist: combineDistributions(a.dist, b.dist, op),
for valB, countB := range b { totalOutcomes: multiplyOutcomeCounts(a.totalOutcomes, b.totalOutcomes),
// Integer exponentiation }
// Standard behavior for non-negative exponents }
// Negative exponents with int base result in 0 (unless -1, 1).
val := 0 func combineIndependentFiltered(a, b distResult, op func(int, int) (int, bool)) distResult {
if valB >= 0 { return distResult{
val = int(math.Pow(float64(valA), float64(valB))) 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 { } else {
// Integer division for 1/(a^-b) usually 0 convolveStepSerial(current, next, sides, singleDieProbability)
val = int(math.Pow(float64(valA), float64(valB)))
} }
res[val] += countA * countB current = next
}
outcomes := make(Distribution, count*(sides-1)+1)
for sum, probability := range current {
if probability != 0 {
outcomes[sum] = probability
} }
} }
return res return normalizeDistribution(outcomes)
} }
// getDiceOutcomes returns a map of all possible outcomes for a dice roll and their frequencies func convolveStepSerial(current, next []float64, sides int, singleDieProbability float64) {
func getDiceOutcomes(count int, sides int, modifier string) map[int]int { for sum, probability := range current {
outcomes := make(map[int]int) if probability == 0 {
continue
if modifier == "H" { }
// Keep only the highest die for face := 1; face <= sides; face++ {
generateHighestOutcomes(count, sides, []int{}, outcomes) next[sum+face] += probability * singleDieProbability
} 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 convolveStepParallel(current, next []float64, sides int, singleDieProbability float64) {
func generateSumOutcomes(remaining int, sides int, current []int, outcomes map[int]int) { workerCount := runtime.GOMAXPROCS(0)
if remaining == 0 { if workerCount > len(current) {
sum := 0 workerCount = len(current)
for _, val := range current {
sum += val
} }
outcomes[sum]++ if workerCount < 2 {
convolveStepSerial(current, next, sides, singleDieProbability)
return return
} }
for die := 1; die <= sides; die++ { chunkSize := (len(current) + workerCount - 1) / workerCount
generateSumOutcomes(remaining-1, sides, append(current, die), outcomes) 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
}
} }
} }
// generateHighestOutcomes recursively generates all highest-die outcomes func highestDieDistribution(count int, sides int) Distribution {
func generateHighestOutcomes(remaining int, sides int, current []int, outcomes map[int]int) { outcomes := make(Distribution, sides)
if remaining == 0 { denominator := float64(sides)
highest := 0 for value := 1; value <= sides; value++ {
for _, val := range current { current := math.Pow(float64(value)/denominator, float64(count))
if val > highest { previous := math.Pow(float64(value-1)/denominator, float64(count))
highest = val probability := current - previous
if probability != 0 {
outcomes[value] = probability
} }
} }
outcomes[highest]++ return normalizeDistribution(outcomes)
return
}
for die := 1; die <= sides; die++ {
generateHighestOutcomes(remaining-1, sides, append(current, die), outcomes)
}
} }
// generateLowestOutcomes recursively generates all lowest-die outcomes func lowestDieDistribution(count int, sides int) Distribution {
func generateLowestOutcomes(remaining int, sides int, current []int, outcomes map[int]int) { outcomes := make(Distribution, sides)
if remaining == 0 { denominator := float64(sides)
lowest := sides + 1 for value := 1; value <= sides; value++ {
for _, val := range current { current := math.Pow(float64(sides-value+1)/denominator, float64(count))
if val < lowest { next := math.Pow(float64(sides-value)/denominator, float64(count))
lowest = val probability := current - next
if probability != 0 {
outcomes[value] = probability
} }
} }
outcomes[lowest]++ return normalizeDistribution(outcomes)
return
}
for die := 1; die <= sides; die++ {
generateLowestOutcomes(remaining-1, sides, append(current, die), outcomes)
}
} }
// GetSortedOutcomes returns sorted unique outcomes func (s *DiceStatistics) populateDerivedFields() {
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
}
// calculateAverageAndMedian calculates the average and most common value
func (s *DiceStatistics) calculateAverageAndMedian() {
if len(s.Results) == 0 { if len(s.Results) == 0 {
s.Average = 0 s.TotalOutcomesText = cloneBigInt(s.TotalOutcomes).String()
s.MostCommon = 0
return return
} }
// Calculate average (mean) s.SortedOutcomes = make([]int, 0, len(s.Results))
sum := 0 s.Percentages = make(map[int]float64, len(s.Results))
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 first := true
maxCount := 0 maxProbability := 0.0
for value, count := range s.Results { for value, probability := range s.Results {
if count > maxCount { s.SortedOutcomes = append(s.SortedOutcomes, value)
maxCount = count 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
s.MostCommon = value s.MostCommon = value
} }
} }
// If there are tied values, choose the smallest one sort.Ints(s.SortedOutcomes)
if maxCount > 0 { s.MaxPercentage = maxProbability * 100
for value, count := range s.Results { }
if count == maxCount && value < s.MostCommon {
s.MostCommon = value // 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
} }