Compare commits
10
Commits
0ed94b90f7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5423fd4217 | ||
|
|
6d35c9443a | ||
|
|
ac0866e4a9 | ||
|
|
1d7a068752 | ||
|
|
c3f2eabe26 | ||
|
|
7b2f7140aa | ||
|
|
2e91081c61 | ||
|
|
09b634d0df | ||
|
|
a7c81948c1 | ||
|
|
e3b5338562 |
@@ -9,10 +9,6 @@
|
|||||||
*.dylib
|
*.dylib
|
||||||
binaries/
|
binaries/
|
||||||
|
|
||||||
# Test binary, built with `go test -c`
|
|
||||||
*.test
|
|
||||||
*_test.go
|
|
||||||
|
|
||||||
# Code coverage profiles and other test artifacts
|
# Code coverage profiles and other test artifacts
|
||||||
*.out
|
*.out
|
||||||
coverage.*
|
coverage.*
|
||||||
|
|||||||
+275
-140
@@ -7,192 +7,302 @@ import (
|
|||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/canvas"
|
"fyne.io/fyne/v2/canvas"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
"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{
|
|
||||||
graph: b,
|
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,
|
||||||
|
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
|
||||||
objects []fyne.CanvasObject
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
return fyne.NewSize(900, 550)
|
return fyne.NewSize(320, 240)
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
size := r.graph.Size()
|
||||||
|
if size.Width == 0 || size.Height == 0 {
|
||||||
// Round up maxPercentage to nearest 5%
|
size = fyne.NewSize(900, 550)
|
||||||
roundedMaxPercent := math.Ceil(maxPercentage/5) * 5
|
|
||||||
if roundedMaxPercent < 5 {
|
|
||||||
roundedMaxPercent = 5
|
|
||||||
}
|
}
|
||||||
|
r.layout(size)
|
||||||
|
}
|
||||||
|
|
||||||
// Padding
|
func (r *barGraphCanvasRenderer) syncData(stats *DiceStatistics) {
|
||||||
topPadding := float32(75)
|
r.title.Show()
|
||||||
bottomPadding := float32(80)
|
r.statsLine1.Show()
|
||||||
leftPadding := float32(100)
|
r.statsLine2.Show()
|
||||||
rightPadding := float32(20)
|
r.yLabel.Show()
|
||||||
|
r.xLabel.Show()
|
||||||
|
r.yAxisLine.Show()
|
||||||
|
r.xAxisLine.Show()
|
||||||
|
|
||||||
graphWidth := float32(900) - leftPadding - rightPadding
|
r.statsLine1.Text = fmt.Sprintf("Range: %d to %d | Total Outcomes: %s", stats.MinValue, stats.MaxValue, stats.TotalOutcomesText)
|
||||||
graphHeight := float32(550) - topPadding - bottomPadding
|
r.statsLine2.Text = fmt.Sprintf("Average: %.2f | Most Common: %d", stats.Average, stats.MostCommon)
|
||||||
|
|
||||||
// Background
|
axisMaxPercent, tickStep := calculateYAxisScale(stats.GetMaxPercentage())
|
||||||
background := canvas.NewRectangle(color.NRGBA{R: 20, G: 20, B: 20, A: 255})
|
numYTicks := int(math.Round(axisMaxPercent / tickStep))
|
||||||
background.Move(fyne.NewPos(0, 0))
|
r.ensureYTicks(numYTicks + 1)
|
||||||
background.Resize(fyne.NewSize(900, 550))
|
|
||||||
r.objects = append(r.objects, background)
|
|
||||||
|
|
||||||
// Y-axis
|
|
||||||
yAxisLine := canvas.NewLine(color.White)
|
|
||||||
yAxisLine.StrokeWidth = 2
|
|
||||||
yAxisLine.Move(fyne.NewPos(leftPadding, topPadding))
|
|
||||||
yAxisLine.Resize(fyne.NewSize(0, graphHeight))
|
|
||||||
r.objects = append(r.objects, yAxisLine)
|
|
||||||
|
|
||||||
// X-axis
|
|
||||||
xAxisLine := canvas.NewLine(color.White)
|
|
||||||
xAxisLine.StrokeWidth = 2
|
|
||||||
xAxisLine.Move(fyne.NewPos(leftPadding, topPadding+graphHeight))
|
|
||||||
xAxisLine.Resize(fyne.NewSize(graphWidth, 0))
|
|
||||||
r.objects = append(r.objects, xAxisLine)
|
|
||||||
|
|
||||||
// Title
|
|
||||||
title := canvas.NewText("Probability Distribution", color.White)
|
|
||||||
title.TextSize = 16
|
|
||||||
title.Move(fyne.NewPos(leftPadding, 5))
|
|
||||||
r.objects = append(r.objects, title)
|
|
||||||
|
|
||||||
// Statistics info line 1
|
|
||||||
statsLine1 := canvas.NewText(fmt.Sprintf("Range: %d to %d | Total Outcomes: %d", stats.MinValue, stats.MaxValue, stats.Total), color.White)
|
|
||||||
statsLine1.TextSize = 11
|
|
||||||
statsLine1.Move(fyne.NewPos(leftPadding, 22))
|
|
||||||
r.objects = append(r.objects, statsLine1)
|
|
||||||
|
|
||||||
// Statistics info line 2
|
|
||||||
statsLine2 := canvas.NewText(fmt.Sprintf("Average: %.2f | Most Common: %d", stats.Average, stats.MostCommon), color.White)
|
|
||||||
statsLine2.TextSize = 11
|
|
||||||
statsLine2.Move(fyne.NewPos(leftPadding, 36))
|
|
||||||
r.objects = append(r.objects, statsLine2)
|
|
||||||
|
|
||||||
// Y-axis label
|
|
||||||
yLabel := canvas.NewText("Probability (%)", color.White)
|
|
||||||
yLabel.TextSize = 12
|
|
||||||
yLabel.Move(fyne.NewPos(15, topPadding+graphHeight/2-40))
|
|
||||||
r.objects = append(r.objects, yLabel)
|
|
||||||
|
|
||||||
// X-axis label
|
|
||||||
xLabel := canvas.NewText("Result Value", color.White)
|
|
||||||
xLabel.TextSize = 12
|
|
||||||
xLabel.Move(fyne.NewPos(leftPadding+graphWidth/2-30, topPadding+graphHeight+50))
|
|
||||||
r.objects = append(r.objects, xLabel)
|
|
||||||
|
|
||||||
// Y-axis tick marks and labels
|
|
||||||
numYTicks := int(roundedMaxPercent/5) + 1
|
|
||||||
for i := 0; i <= numYTicks; i++ {
|
for i := 0; i <= numYTicks; i++ {
|
||||||
percent := float64(i) * 5
|
percent := float64(i) * tickStep
|
||||||
|
r.yTicks[i].Show()
|
||||||
yPos := topPadding + graphHeight - (float32(percent/roundedMaxPercent) * graphHeight)
|
r.yTickLabels[i].Text = formatPercentLabel(percent)
|
||||||
|
r.yTickLabels[i].Show()
|
||||||
// Tick mark
|
}
|
||||||
tick := canvas.NewLine(color.White)
|
for i := numYTicks + 1; i < len(r.yTicks); i++ {
|
||||||
tick.StrokeWidth = 1
|
r.yTicks[i].Hide()
|
||||||
tick.Move(fyne.NewPos(leftPadding-5, yPos))
|
r.yTickLabels[i].Hide()
|
||||||
tick.Resize(fyne.NewSize(5, 0))
|
}
|
||||||
r.objects = append(r.objects, tick)
|
|
||||||
|
outcomes := stats.GetSortedOutcomes()
|
||||||
// Label
|
r.ensureBars(len(outcomes))
|
||||||
label := canvas.NewText(fmt.Sprintf("%.0f%%", percent), color.White)
|
for i := range outcomes {
|
||||||
label.TextSize = 10
|
r.bars[i].Show()
|
||||||
label.Move(fyne.NewPos(leftPadding-50, yPos-7))
|
r.barLabels[i].Hide()
|
||||||
r.objects = append(r.objects, label)
|
}
|
||||||
|
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()
|
||||||
|
|
||||||
|
topPadding := clamp(size.Height*0.14, 48, 75)
|
||||||
|
bottomPadding := clamp(size.Height*0.15, 56, 80)
|
||||||
|
leftPadding := clamp(size.Width*0.12, 60, 100)
|
||||||
|
rightPadding := clamp(size.Width*0.03, 16, 24)
|
||||||
|
|
||||||
|
graphWidth := size.Width - leftPadding - rightPadding
|
||||||
|
graphHeight := size.Height - topPadding - bottomPadding
|
||||||
|
if graphWidth <= 0 || graphHeight <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
titleSize := clamp(size.Height*0.03, 12, 16)
|
||||||
|
bodyTextSize := clamp(size.Height*0.02, 9, 11)
|
||||||
|
labelTextSize := clamp(size.Height*0.022, 10, 12)
|
||||||
|
tickTextSize := clamp(size.Height*0.018, 8, 10)
|
||||||
|
|
||||||
|
r.yAxisLine.Move(fyne.NewPos(leftPadding, topPadding))
|
||||||
|
r.yAxisLine.Resize(fyne.NewSize(0, graphHeight))
|
||||||
|
r.xAxisLine.Move(fyne.NewPos(leftPadding, topPadding+graphHeight))
|
||||||
|
r.xAxisLine.Resize(fyne.NewSize(graphWidth, 0))
|
||||||
|
|
||||||
|
r.title.TextSize = titleSize
|
||||||
|
r.title.Move(fyne.NewPos(leftPadding, 5))
|
||||||
|
|
||||||
|
r.statsLine1.TextSize = bodyTextSize
|
||||||
|
r.statsLine1.Move(fyne.NewPos(leftPadding, 5+r.title.MinSize().Height))
|
||||||
|
|
||||||
|
r.statsLine2.TextSize = bodyTextSize
|
||||||
|
r.statsLine2.Move(fyne.NewPos(leftPadding, 5+r.title.MinSize().Height+r.statsLine1.MinSize().Height))
|
||||||
|
|
||||||
|
r.yLabel.TextSize = labelTextSize
|
||||||
|
r.yLabel.Move(fyne.NewPos(clamp(leftPadding*0.15, 8, 15), topPadding+graphHeight/2-r.yLabel.MinSize().Height/2))
|
||||||
|
|
||||||
|
r.xLabel.TextSize = labelTextSize
|
||||||
|
r.xLabel.Move(fyne.NewPos(leftPadding+graphWidth/2-r.xLabel.MinSize().Width/2, topPadding+graphHeight+clamp(bottomPadding*0.35, 18, 28)))
|
||||||
|
|
||||||
|
numYTicks := int(math.Round(axisMaxPercent / tickStep))
|
||||||
|
for i := 0; i <= numYTicks && i < len(r.yTicks); i++ {
|
||||||
|
percent := float64(i) * tickStep
|
||||||
|
yPos := topPadding + graphHeight - (float32(percent/axisMaxPercent) * graphHeight)
|
||||||
|
|
||||||
|
r.yTicks[i].Move(fyne.NewPos(leftPadding-5, yPos))
|
||||||
|
r.yTicks[i].Resize(fyne.NewSize(5, 0))
|
||||||
|
r.yTickLabels[i].TextSize = tickTextSize
|
||||||
|
r.yTickLabels[i].Move(fyne.NewPos(leftPadding-50, yPos-7))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw bars
|
|
||||||
numBars := len(outcomes)
|
numBars := len(outcomes)
|
||||||
barWidth := (graphWidth - float32(numBars+1)*2) / float32(numBars)
|
if numBars == 0 {
|
||||||
if barWidth < 2 {
|
r.refreshObjects()
|
||||||
barWidth = 2
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
barSpacing := float32(2)
|
barSpacing := float32(2)
|
||||||
|
totalSpacing := float32(numBars+1) * barSpacing
|
||||||
|
barWidth := (graphWidth - totalSpacing) / float32(numBars)
|
||||||
|
if 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]
|
||||||
|
barHeight := (float32(percentage) / float32(axisMaxPercent)) * graphHeight
|
||||||
|
xPos := leftPadding + barSpacing + float32(i)*(barWidth+barSpacing)
|
||||||
|
|
||||||
// Bar height proportional to percentage
|
r.bars[i].Move(fyne.NewPos(xPos, topPadding+graphHeight-barHeight))
|
||||||
barHeight := (float32(percentage) / float32(roundedMaxPercent)) * graphHeight
|
r.bars[i].Resize(fyne.NewSize(barWidth, barHeight))
|
||||||
|
|
||||||
// X position
|
r.barLabels[i].TextSize = tickTextSize
|
||||||
xPos := leftPadding + float32(i)*(barWidth+barSpacing*2) + barSpacing
|
r.barLabels[i].Alignment = fyne.TextAlignCenter
|
||||||
|
if i == 0 || i == numBars-1 || (labelStep > 0 && i%labelStep == 0 && i < numBars-labelStep) {
|
||||||
// Draw bar
|
r.barLabels[i].Text = fmt.Sprintf("%d", value)
|
||||||
bar := canvas.NewRectangle(color.NRGBA{R: 100, G: 180, B: 255, A: 255})
|
r.barLabels[i].Show()
|
||||||
bar.Move(fyne.NewPos(xPos, topPadding+graphHeight-barHeight))
|
r.barLabels[i].Move(fyne.NewPos(xPos+barWidth/2-r.barLabels[i].MinSize().Width/2, topPadding+graphHeight+10))
|
||||||
bar.Resize(fyne.NewSize(barWidth, barHeight))
|
} else {
|
||||||
r.objects = append(r.objects, bar)
|
r.barLabels[i].Hide()
|
||||||
|
|
||||||
// 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 = 10
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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})
|
||||||
|
label := canvas.NewText("", color.White)
|
||||||
|
r.bars = append(r.bars, bar)
|
||||||
|
r.barLabels = append(r.barLabels, label)
|
||||||
|
r.objects = append(r.objects, bar, 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 {
|
||||||
|
if value < minValue {
|
||||||
|
return minValue
|
||||||
|
}
|
||||||
|
if value > maxValue {
|
||||||
|
return maxValue
|
||||||
|
}
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -204,14 +314,41 @@ func calculateLabelStep(graphWidth float32, numBars int) int {
|
|||||||
return labelStep
|
return labelStep
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *barGraphCanvasRenderer) Objects() []fyne.CanvasObject {
|
func calculateYAxisScale(maxPercentage float64) (axisMax float64, tickStep float64) {
|
||||||
return r.objects
|
if maxPercentage <= 0 {
|
||||||
|
return 1, 0.2
|
||||||
|
}
|
||||||
|
|
||||||
|
targetTicks := 5.0
|
||||||
|
rawStep := maxPercentage / targetTicks
|
||||||
|
magnitude := math.Pow(10, math.Floor(math.Log10(rawStep)))
|
||||||
|
normalized := rawStep / magnitude
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case normalized <= 1:
|
||||||
|
tickStep = 1 * magnitude
|
||||||
|
case normalized <= 2:
|
||||||
|
tickStep = 2 * magnitude
|
||||||
|
case normalized <= 2.5:
|
||||||
|
tickStep = 2.5 * magnitude
|
||||||
|
case normalized <= 5:
|
||||||
|
tickStep = 5 * magnitude
|
||||||
|
default:
|
||||||
|
tickStep = 10 * magnitude
|
||||||
|
}
|
||||||
|
|
||||||
|
axisMax = math.Ceil(maxPercentage/tickStep) * tickStep
|
||||||
|
return axisMax, tickStep
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *barGraphCanvasRenderer) Destroy() {
|
func formatPercentLabel(percent float64) string {
|
||||||
|
if math.Abs(percent-math.Round(percent)) < 0.0001 {
|
||||||
|
return fmt.Sprintf("%.0f%%", percent)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f%%", percent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShowStatisticsWindow creates and shows a statistics window for the given expression
|
// 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 {
|
||||||
@@ -219,12 +356,10 @@ 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(graph)
|
window.SetContent(container.NewMax(graph))
|
||||||
window.Resize(fyne.NewSize(900, 550))
|
window.Resize(fyne.NewSize(900, 550))
|
||||||
window.Show()
|
window.Show()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,517 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCalculateDice_BasicDiceRoll tests simple dice rolls
|
||||||
|
func TestCalculateDice_BasicDiceRoll(t *testing.T) {
|
||||||
|
result, diceRolls, err := CalculateDice("d20")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CalculateDice failed: %v", err)
|
||||||
|
}
|
||||||
|
if result < 1 || result > 20 {
|
||||||
|
t.Errorf("d20 should roll 1-20, got %v", result)
|
||||||
|
}
|
||||||
|
if !strings.Contains(diceRolls, "1d20") {
|
||||||
|
t.Errorf("diceRolls should contain '1d20', got %q", diceRolls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDice_MultipleDice tests multiple dice
|
||||||
|
func TestCalculateDice_MultipleDice(t *testing.T) {
|
||||||
|
result, _, err := CalculateDice("2d6")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CalculateDice failed: %v", err)
|
||||||
|
}
|
||||||
|
if result < 2 || result > 12 {
|
||||||
|
t.Errorf("2d6 should roll 2-12, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDice_AdditionWithDice tests dice with addition
|
||||||
|
func TestCalculateDice_AdditionWithDice(t *testing.T) {
|
||||||
|
result, _, err := CalculateDice("1d6+5")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CalculateDice failed: %v", err)
|
||||||
|
}
|
||||||
|
if result < 6 || result > 11 {
|
||||||
|
t.Errorf("1d6+5 should roll 6-11, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDice_SubtractionWithDice tests dice with subtraction
|
||||||
|
func TestCalculateDice_SubtractionWithDice(t *testing.T) {
|
||||||
|
result, _, err := CalculateDice("2d10-5")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CalculateDice failed: %v", err)
|
||||||
|
}
|
||||||
|
if result < -3 || result > 15 {
|
||||||
|
t.Errorf("2d10-5 should roll -3 to 15, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDice_MultiplicationWithDice tests dice with multiplication
|
||||||
|
func TestCalculateDice_MultiplicationWithDice(t *testing.T) {
|
||||||
|
result, _, err := CalculateDice("1d5*2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CalculateDice failed: %v", err)
|
||||||
|
}
|
||||||
|
if result < 2 || result > 10 {
|
||||||
|
t.Errorf("1d5*2 should roll 2-10, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDice_DivisionWithDice tests dice with division
|
||||||
|
func TestCalculateDice_DivisionWithDice(t *testing.T) {
|
||||||
|
result, _, err := CalculateDice("1d6/2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CalculateDice failed: %v", err)
|
||||||
|
}
|
||||||
|
if result < 0 || result > 3 {
|
||||||
|
t.Errorf("1d6/2 should roll 0-3, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDice_HighestDiceModifier tests the 'H' modifier
|
||||||
|
func TestCalculateDice_HighestDiceModifier(t *testing.T) {
|
||||||
|
// 2d20H should return only the highest die
|
||||||
|
result, _, err := CalculateDice("2d20H")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CalculateDice failed: %v", err)
|
||||||
|
}
|
||||||
|
if result < 1 || result > 20 {
|
||||||
|
t.Errorf("2d20H should roll 1-20 (highest of two d20s), got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDice_LowestDiceModifier tests the 'L' modifier
|
||||||
|
func TestCalculateDice_LowestDiceModifier(t *testing.T) {
|
||||||
|
// 2d20L should return only the lowest die
|
||||||
|
result, _, err := CalculateDice("2d20L")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CalculateDice failed: %v", err)
|
||||||
|
}
|
||||||
|
if result < 1 || result > 20 {
|
||||||
|
t.Errorf("2d20L should roll 1-20 (lowest of two d20s), got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDice_ComplexExpression tests complex mathematical expressions
|
||||||
|
func TestCalculateDice_ComplexExpression(t *testing.T) {
|
||||||
|
result, _, err := CalculateDice("2d6+3*2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CalculateDice failed: %v", err)
|
||||||
|
}
|
||||||
|
// 2d6 (2-12) + 3*2 (6) = 8-18
|
||||||
|
if result < 8 || result > 18 {
|
||||||
|
t.Errorf("2d6+3*2 should roll 8-18, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDice_ParenthesesExpression tests expressions with parentheses
|
||||||
|
func TestCalculateDice_ParenthesesExpression(t *testing.T) {
|
||||||
|
result, _, err := CalculateDice("(1d4+2)*3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CalculateDice failed: %v", err)
|
||||||
|
}
|
||||||
|
// (1d4+2)*3 = (3-6)*3 = 9-18
|
||||||
|
if result < 9 || result > 18 {
|
||||||
|
t.Errorf("(1d4+2)*3 should roll 9-18, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDice_MultipleAddends tests multiple addends
|
||||||
|
func TestCalculateDice_MultipleAddends(t *testing.T) {
|
||||||
|
result, _, err := CalculateDice("1d4+1d6+1d8")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CalculateDice failed: %v", err)
|
||||||
|
}
|
||||||
|
// 1d4 (1-4) + 1d6 (1-6) + 1d8 (1-8) = 3-18
|
||||||
|
if result < 3 || result > 18 {
|
||||||
|
t.Errorf("1d4+1d6+1d8 should roll 3-18, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDice_PrefixModifier tests prefix H/L modifiers
|
||||||
|
func TestCalculateDice_PrefixModifier(t *testing.T) {
|
||||||
|
result, _, err := CalculateDice("H2d20")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CalculateDice failed: %v", err)
|
||||||
|
}
|
||||||
|
if result < 1 || result > 20 {
|
||||||
|
t.Errorf("H2d20 should roll 1-20 (highest of two d20s), got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDice_EmptyExpression tests error handling for empty expression
|
||||||
|
func TestCalculateDice_EmptyExpression(t *testing.T) {
|
||||||
|
_, _, err := CalculateDice("")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Empty expression should return error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDice_InvalidDiceCount tests error handling for invalid dice count
|
||||||
|
func TestCalculateDice_InvalidDiceCount(t *testing.T) {
|
||||||
|
_, _, err := CalculateDice("0d6")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("0d6 should return error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDice_InvalidDiceSides tests error handling for invalid dice sides
|
||||||
|
func TestCalculateDice_InvalidDiceSides(t *testing.T) {
|
||||||
|
_, _, err := CalculateDice("1d0")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("1d0 should return error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDice_PlaceholderDice tests error handling for placeholder dice
|
||||||
|
func TestCalculateDice_PlaceholderDice(t *testing.T) {
|
||||||
|
_, _, err := CalculateDice("dx")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("dx should return error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateMathExpression_SimpleAddition tests simple addition
|
||||||
|
func TestEvaluateMathExpression_SimpleAddition(t *testing.T) {
|
||||||
|
result, err := evaluateMathExpression("5+3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluateMathExpression failed: %v", err)
|
||||||
|
}
|
||||||
|
if result != 8 {
|
||||||
|
t.Errorf("5+3 should be 8, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateMathExpression_Subtraction tests subtraction
|
||||||
|
func TestEvaluateMathExpression_Subtraction(t *testing.T) {
|
||||||
|
result, err := evaluateMathExpression("10-3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluateMathExpression failed: %v", err)
|
||||||
|
}
|
||||||
|
if result != 7 {
|
||||||
|
t.Errorf("10-3 should be 7, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateMathExpression_Multiplication tests multiplication
|
||||||
|
func TestEvaluateMathExpression_Multiplication(t *testing.T) {
|
||||||
|
result, err := evaluateMathExpression("4*5")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluateMathExpression failed: %v", err)
|
||||||
|
}
|
||||||
|
if result != 20 {
|
||||||
|
t.Errorf("4*5 should be 20, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateMathExpression_Division tests division
|
||||||
|
func TestEvaluateMathExpression_Division(t *testing.T) {
|
||||||
|
result, err := evaluateMathExpression("20/4")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluateMathExpression failed: %v", err)
|
||||||
|
}
|
||||||
|
if result != 5 {
|
||||||
|
t.Errorf("20/4 should be 5, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateMathExpression_OperatorPrecedence tests operator precedence (multiplication before addition)
|
||||||
|
func TestEvaluateMathExpression_OperatorPrecedence(t *testing.T) {
|
||||||
|
result, err := evaluateMathExpression("2+3*4")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluateMathExpression failed: %v", err)
|
||||||
|
}
|
||||||
|
if result != 14 {
|
||||||
|
t.Errorf("2+3*4 should be 14, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateMathExpression_Parentheses tests parentheses override precedence
|
||||||
|
func TestEvaluateMathExpression_Parentheses(t *testing.T) {
|
||||||
|
result, err := evaluateMathExpression("(2+3)*4")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluateMathExpression failed: %v", err)
|
||||||
|
}
|
||||||
|
if result != 20 {
|
||||||
|
t.Errorf("(2+3)*4 should be 20, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateMathExpression_Exponentiation tests exponentiation
|
||||||
|
func TestEvaluateMathExpression_Exponentiation(t *testing.T) {
|
||||||
|
result, err := evaluateMathExpression("2^3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluateMathExpression failed: %v", err)
|
||||||
|
}
|
||||||
|
if result != 8 {
|
||||||
|
t.Errorf("2^3 should be 8, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateMathExpression_UnaryMinus tests unary minus
|
||||||
|
func TestEvaluateMathExpression_UnaryMinus(t *testing.T) {
|
||||||
|
result, err := evaluateMathExpression("-5+10")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluateMathExpression failed: %v", err)
|
||||||
|
}
|
||||||
|
if result != 5 {
|
||||||
|
t.Errorf("-5+10 should be 5, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateMathExpression_FloatingPoint tests floating point numbers
|
||||||
|
func TestEvaluateMathExpression_FloatingPoint(t *testing.T) {
|
||||||
|
result, err := evaluateMathExpression("3.5+2.5")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluateMathExpression failed: %v", err)
|
||||||
|
}
|
||||||
|
if result != 6 {
|
||||||
|
t.Errorf("3.5+2.5 should be 6, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateMathExpression_DivisionByZero tests division by zero error
|
||||||
|
func TestEvaluateMathExpression_DivisionByZero(t *testing.T) {
|
||||||
|
_, err := evaluateMathExpression("5/0")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Division by zero should return error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateMathExpression_MissingClosingParen tests missing closing parenthesis error
|
||||||
|
func TestEvaluateMathExpression_MissingClosingParen(t *testing.T) {
|
||||||
|
_, err := evaluateMathExpression("(2+3")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Missing closing parenthesis should return error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateMathExpression_InvalidCharacter tests invalid character error
|
||||||
|
func TestEvaluateMathExpression_InvalidCharacter(t *testing.T) {
|
||||||
|
_, err := evaluateMathExpression("5@3")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Invalid character should return error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateMathExpression_Whitespace tests whitespace handling
|
||||||
|
func TestEvaluateMathExpression_Whitespace(t *testing.T) {
|
||||||
|
result, err := evaluateMathExpression(" 5 + 3 ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluateMathExpression failed: %v", err)
|
||||||
|
}
|
||||||
|
if result != 8 {
|
||||||
|
t.Errorf("' 5 + 3 ' should be 8, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateMathExpression_ComplexExpression tests complex mathematical expressions
|
||||||
|
func TestEvaluateMathExpression_ComplexExpression(t *testing.T) {
|
||||||
|
result, err := evaluateMathExpression("((2+3)*4-5)/3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluateMathExpression failed: %v", err)
|
||||||
|
}
|
||||||
|
// ((2+3)*4-5)/3 = (5*4-5)/3 = (20-5)/3 = 15/3 = 5
|
||||||
|
if result != 5 {
|
||||||
|
t.Errorf("((2+3)*4-5)/3 should be 5, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRollDiceSet_Range tests rollDiceSet produces values in correct range
|
||||||
|
func TestRollDiceSet_Range(t *testing.T) {
|
||||||
|
rolls := rollDiceSet(10, 20)
|
||||||
|
if len(rolls) != 10 {
|
||||||
|
t.Errorf("rollDiceSet(10, 20) should return 10 rolls, got %d", len(rolls))
|
||||||
|
}
|
||||||
|
for i, roll := range rolls {
|
||||||
|
if roll < 1 || roll > 20 {
|
||||||
|
t.Errorf("Roll %d is %d, expected 1-20", i, roll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRollDiceSet_Variability tests rollDiceSet produces different values
|
||||||
|
func TestRollDiceSet_Variability(t *testing.T) {
|
||||||
|
rolls := rollDiceSet(100, 6)
|
||||||
|
seenValues := make(map[int]bool)
|
||||||
|
for _, roll := range rolls {
|
||||||
|
seenValues[roll] = true
|
||||||
|
}
|
||||||
|
// With 100 rolls of 1d6, we should see multiple different values
|
||||||
|
if len(seenValues) < 3 {
|
||||||
|
t.Errorf("100 rolls of 1d6 should see at least 3 different values, got %d", len(seenValues))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDice_OutputFormat tests the output format
|
||||||
|
func TestCalculateDice_OutputFormat(t *testing.T) {
|
||||||
|
_, diceRolls, err := CalculateDice("2d6")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CalculateDice failed: %v", err)
|
||||||
|
}
|
||||||
|
// Should contain information about the rolls
|
||||||
|
if !strings.Contains(diceRolls, "2d6") {
|
||||||
|
t.Errorf("diceRolls should contain '2d6', got %q", diceRolls)
|
||||||
|
}
|
||||||
|
if !strings.Contains(diceRolls, "(") || !strings.Contains(diceRolls, ")") {
|
||||||
|
t.Errorf("diceRolls should be formatted with parentheses, got %q", diceRolls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDice_PrefixAndSuffixModifiers tests that suffix modifier takes priority
|
||||||
|
func TestCalculateDice_PrefixAndSuffixModifiers(t *testing.T) {
|
||||||
|
// When both prefix and suffix modifiers are present, suffix should take priority
|
||||||
|
result, _, err := CalculateDice("H2d20L")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CalculateDice failed: %v", err)
|
||||||
|
}
|
||||||
|
// Should use L (suffix) modifier, not H
|
||||||
|
if result < 1 || result > 20 {
|
||||||
|
t.Errorf("H2d20L should use L modifier and roll 1-20, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateMathExpression_LargeNumbers tests large number handling
|
||||||
|
func TestEvaluateMathExpression_LargeNumbers(t *testing.T) {
|
||||||
|
result, err := evaluateMathExpression("1000000+2000000")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluateMathExpression failed: %v", err)
|
||||||
|
}
|
||||||
|
if result != 3000000 {
|
||||||
|
t.Errorf("1000000+2000000 should be 3000000, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateMathExpression_ExponentiationPrecedence tests exponentiation precedence
|
||||||
|
func TestEvaluateMathExpression_ExponentiationPrecedence(t *testing.T) {
|
||||||
|
result, err := evaluateMathExpression("2+3^2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluateMathExpression failed: %v", err)
|
||||||
|
}
|
||||||
|
// 2 + (3^2) = 2 + 9 = 11
|
||||||
|
if result != 11 {
|
||||||
|
t.Errorf("2+3^2 should be 11, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateMathExpression_NestedParentheses tests nested parentheses
|
||||||
|
func TestEvaluateMathExpression_NestedParentheses(t *testing.T) {
|
||||||
|
result, err := evaluateMathExpression("((10))")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluateMathExpression failed: %v", err)
|
||||||
|
}
|
||||||
|
if result != 10 {
|
||||||
|
t.Errorf("((10)) should be 10, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDice_NegativeResult tests expressions that can produce negative results
|
||||||
|
func TestCalculateDice_NegativeResult(t *testing.T) {
|
||||||
|
result, _, err := CalculateDice("1d4-10")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CalculateDice failed: %v", err)
|
||||||
|
}
|
||||||
|
// 1d4 (1-4) - 10 = -9 to -6
|
||||||
|
if result < -9 || result > -6 {
|
||||||
|
t.Errorf("1d4-10 should roll -9 to -6, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExpandDiceNotation_SingleDice tests expansion of single dice notation
|
||||||
|
func TestExpandDiceNotation_SingleDice(t *testing.T) {
|
||||||
|
// This test verifies that dice notation expands correctly
|
||||||
|
// We can't test exact values due to randomness, but we can test the format
|
||||||
|
expanded, diceRolls, err := expandDiceNotation("1d6")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expandDiceNotation failed: %v", err)
|
||||||
|
}
|
||||||
|
// expanded should be a number between 1 and 6
|
||||||
|
val, err := evaluateMathExpression(expanded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expanded result should be valid: %v", err)
|
||||||
|
}
|
||||||
|
if val < 1 || val > 6 {
|
||||||
|
t.Errorf("1d6 should expand to 1-6, got %v", val)
|
||||||
|
}
|
||||||
|
if !strings.Contains(diceRolls, "1d6") {
|
||||||
|
t.Errorf("diceRolls should contain '1d6', got %q", diceRolls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExpandDiceNotation_WithModifier tests expansion with H/L modifier
|
||||||
|
func TestExpandDiceNotation_WithModifier(t *testing.T) {
|
||||||
|
expanded, _, err := expandDiceNotation("2d20H")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expandDiceNotation failed: %v", err)
|
||||||
|
}
|
||||||
|
val, err := evaluateMathExpression(expanded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expanded result should be valid: %v", err)
|
||||||
|
}
|
||||||
|
if val < 1 || val > 20 {
|
||||||
|
t.Errorf("2d20H should expand to 1-20, got %v", val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDice_PowerOperator tests the power/exponentiation operator
|
||||||
|
func TestCalculateDice_PowerOperator(t *testing.T) {
|
||||||
|
result, _, err := CalculateDice("2^3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CalculateDice failed: %v", err)
|
||||||
|
}
|
||||||
|
if result != 8 {
|
||||||
|
t.Errorf("2^3 should be 8, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDice_ZeroDice tests zero values
|
||||||
|
func TestCalculateDice_ZeroDice(t *testing.T) {
|
||||||
|
result, _, err := CalculateDice("0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CalculateDice failed: %v", err)
|
||||||
|
}
|
||||||
|
if result != 0 {
|
||||||
|
t.Errorf("0 should be 0, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateMathExpression_NestedExpression tests deeply nested expressions
|
||||||
|
func TestEvaluateMathExpression_NestedExpression(t *testing.T) {
|
||||||
|
result, err := evaluateMathExpression("(((2+3)))")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluateMathExpression failed: %v", err)
|
||||||
|
}
|
||||||
|
if result != 5 {
|
||||||
|
t.Errorf("(((2+3))) should be 5, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDice_MultipleOperations tests multiple different operations
|
||||||
|
func TestCalculateDice_MultipleOperations(t *testing.T) {
|
||||||
|
result, _, err := CalculateDice("1d4+2-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CalculateDice failed: %v", err)
|
||||||
|
}
|
||||||
|
// 1d4 (1-4) + 2 - 1 = 2-5
|
||||||
|
if result < 2 || result > 5 {
|
||||||
|
t.Errorf("1d4+2-1 should roll 2-5, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateMathExpression_ChainedExponentiation tests chained exponentiation
|
||||||
|
func TestEvaluateMathExpression_ChainedExponentiation(t *testing.T) {
|
||||||
|
result, err := evaluateMathExpression("2^2^2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluateMathExpression failed: %v", err)
|
||||||
|
}
|
||||||
|
// Left-associative: (2^2)^2 = 4^2 = 16
|
||||||
|
if result != 16 {
|
||||||
|
t.Errorf("2^2^2 (left-associative) should be 16, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -36,5 +36,6 @@ require (
|
|||||||
golang.org/x/net v0.35.0 // indirect
|
golang.org/x/net v0.35.0 // indirect
|
||||||
golang.org/x/sys v0.30.0 // indirect
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
golang.org/x/text v0.22.0 // indirect
|
golang.org/x/text v0.22.0 // indirect
|
||||||
|
gonum.org/v1/gonum v0.14.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
|||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
|
gonum.org/v1/gonum v0.14.0 h1:2NiG67LD1tEH0D7kM+ps2V+fXmsAnpUeec7n8tcr4S0=
|
||||||
|
gonum.org/v1/gonum v0.14.0/go.mod h1:AoWeoz0becf9QMWtE8iWXNXc27fK4fNeHNf/oMejGfU=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image/color"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -20,8 +21,10 @@ func main() {
|
|||||||
var historyList *widget.List
|
var historyList *widget.List
|
||||||
|
|
||||||
// Dice input bar
|
// Dice input bar
|
||||||
diceInputEntry := newCustomEntry(fyne.CurrentApp().Settings().Theme().Size(theme.SizeNameText) * 2)
|
inputTextSize := fyne.CurrentApp().Settings().Theme().Size(theme.SizeNameText) * 2
|
||||||
|
diceInputEntry := newCustomEntry(inputTextSize)
|
||||||
diceInputEntry.SetPlaceHolder("e.g., 2d20H, 3d6+5")
|
diceInputEntry.SetPlaceHolder("e.g., 2d20H, 3d6+5")
|
||||||
|
diceInputContainer := container.NewThemeOverride(diceInputEntry, newSizeOverrideTheme(fyne.CurrentApp().Settings().Theme(), inputTextSize))
|
||||||
|
|
||||||
historyList = widget.NewList(
|
historyList = widget.NewList(
|
||||||
func() int {
|
func() int {
|
||||||
@@ -62,6 +65,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearHistory := func() {
|
||||||
|
calculations = nil
|
||||||
|
diceInputEntry.SetText("")
|
||||||
|
historyList.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
// Roll button
|
// Roll button
|
||||||
rollButton := newCustomButton2WithImportance("ROLL", widget.HighImportance, roll)
|
rollButton := newCustomButton2WithImportance("ROLL", widget.HighImportance, roll)
|
||||||
|
|
||||||
@@ -69,92 +78,134 @@ func main() {
|
|||||||
roll()
|
roll()
|
||||||
}
|
}
|
||||||
|
|
||||||
buttons := []fyne.CanvasObject{
|
graphButton := newCustomButton2WithColors("📊", color.NRGBA{R: 46, G: 160, B: 67, A: 255}, color.White, func() {
|
||||||
newCustomButton2("H", func() {
|
diceInput := strings.TrimSpace(diceInputEntry.Text)
|
||||||
diceInputEntry.SetText(diceInputEntry.Text + "H")
|
if diceInput == "" {
|
||||||
}),
|
return
|
||||||
newCustomButton2("dX", func() {
|
}
|
||||||
diceInputEntry.SetText(diceInputEntry.Text + "d")
|
ShowStatisticsWindow(diceInput)
|
||||||
}),
|
roll()
|
||||||
newCustomButton2("d4", func() {
|
})
|
||||||
diceInputEntry.SetText(diceInputEntry.Text + "d4")
|
|
||||||
}),
|
lButton := newCustomButton2("L", func() {
|
||||||
newCustomButton2("d6", func() {
|
diceInputEntry.SetText(diceInputEntry.Text + "L")
|
||||||
diceInputEntry.SetText(diceInputEntry.Text + "d6")
|
})
|
||||||
}),
|
hButton := newCustomButton2("H", func() {
|
||||||
newCustomButton2("d8", func() {
|
diceInputEntry.SetText(diceInputEntry.Text + "H")
|
||||||
diceInputEntry.SetText(diceInputEntry.Text + "d8")
|
})
|
||||||
}),
|
dxButton := newCustomButton2("dX", func() {
|
||||||
newCustomButton2("L", func() {
|
diceInputEntry.SetText(diceInputEntry.Text + "d")
|
||||||
diceInputEntry.SetText(diceInputEntry.Text + "L")
|
})
|
||||||
}),
|
d4Button := newCustomButton2("d4", func() {
|
||||||
newCustomButton2("d10", func() {
|
diceInputEntry.SetText(diceInputEntry.Text + "d4")
|
||||||
diceInputEntry.SetText(diceInputEntry.Text + "d10")
|
})
|
||||||
}),
|
d6Button := newCustomButton2("d6", func() {
|
||||||
newCustomButton2("d12", func() {
|
diceInputEntry.SetText(diceInputEntry.Text + "d6")
|
||||||
diceInputEntry.SetText(diceInputEntry.Text + "d12")
|
})
|
||||||
}),
|
d8Button := newCustomButton2("d8", func() {
|
||||||
newCustomButton2("d20", func() {
|
diceInputEntry.SetText(diceInputEntry.Text + "d8")
|
||||||
diceInputEntry.SetText(diceInputEntry.Text + "d20")
|
})
|
||||||
}),
|
d10Button := newCustomButton2("d10", func() {
|
||||||
newCustomButton2("d100", func() {
|
diceInputEntry.SetText(diceInputEntry.Text + "d10")
|
||||||
diceInputEntry.SetText(diceInputEntry.Text + "d100")
|
})
|
||||||
}),
|
d12Button := newCustomButton2("d12", func() {
|
||||||
createCalcButton("7", diceInputEntry),
|
diceInputEntry.SetText(diceInputEntry.Text + "d12")
|
||||||
createCalcButton("8", diceInputEntry),
|
})
|
||||||
createCalcButton("9", diceInputEntry),
|
d20Button := newCustomButton2("d20", func() {
|
||||||
newCustomButton2("*", func() {
|
diceInputEntry.SetText(diceInputEntry.Text + "d20")
|
||||||
diceInputEntry.SetText(diceInputEntry.Text + "*")
|
})
|
||||||
}),
|
d100Button := newCustomButton2("d100", func() {
|
||||||
newCustomButton2("BKSP", func() {
|
diceInputEntry.SetText(diceInputEntry.Text + "d100")
|
||||||
text := diceInputEntry.Text
|
})
|
||||||
if len(text) > 0 {
|
sevenButton := createCalcButton("7", diceInputEntry)
|
||||||
diceInputEntry.SetText(text[:len(text)-1])
|
eightButton := createCalcButton("8", diceInputEntry)
|
||||||
}
|
nineButton := createCalcButton("9", diceInputEntry)
|
||||||
}),
|
fourButton := createCalcButton("4", diceInputEntry)
|
||||||
createCalcButton("4", diceInputEntry),
|
fiveButton := createCalcButton("5", diceInputEntry)
|
||||||
createCalcButton("5", diceInputEntry),
|
sixButton := createCalcButton("6", diceInputEntry)
|
||||||
createCalcButton("6", diceInputEntry),
|
oneButton := createCalcButton("1", diceInputEntry)
|
||||||
newCustomButton2("/", func() {
|
twoButton := createCalcButton("2", diceInputEntry)
|
||||||
diceInputEntry.SetText(diceInputEntry.Text + "/")
|
threeButton := createCalcButton("3", diceInputEntry)
|
||||||
}),
|
zeroButton := createCalcButton("0", diceInputEntry)
|
||||||
newCustomButton2("C", func() {
|
multiplyButton := newCustomButton2("*", func() {
|
||||||
diceInputEntry.SetText("")
|
diceInputEntry.SetText(diceInputEntry.Text + "*")
|
||||||
}),
|
})
|
||||||
createCalcButton("1", diceInputEntry),
|
backspaceButton := newCustomButton2("BKSP", func() {
|
||||||
createCalcButton("2", diceInputEntry),
|
text := diceInputEntry.Text
|
||||||
createCalcButton("3", diceInputEntry),
|
if len(text) > 0 {
|
||||||
newCustomButton2("+", func() {
|
diceInputEntry.SetText(text[:len(text)-1])
|
||||||
diceInputEntry.SetText(diceInputEntry.Text + "+")
|
}
|
||||||
}),
|
})
|
||||||
newCustomButton2("📊", func() {
|
divideButton := newCustomButton2("/", func() {
|
||||||
diceInput := strings.TrimSpace(diceInputEntry.Text)
|
diceInputEntry.SetText(diceInputEntry.Text + "/")
|
||||||
if diceInput == "" {
|
})
|
||||||
return
|
clearButton := newCustomButton2("C", func() {
|
||||||
}
|
diceInputEntry.SetText("")
|
||||||
ShowStatisticsWindow(diceInput)
|
})
|
||||||
roll()
|
addButton := newCustomButton2("+", func() {
|
||||||
}),
|
diceInputEntry.SetText(diceInputEntry.Text + "+")
|
||||||
newCustomButton2(".", func() {
|
})
|
||||||
diceInputEntry.SetText(diceInputEntry.Text + ".")
|
acButton := newCustomButton2WithColors("AC", color.NRGBA{R: 198, G: 40, B: 40, A: 255}, color.White, clearHistory)
|
||||||
}),
|
decimalButton := newCustomButton2(".", func() {
|
||||||
createCalcButton("0", diceInputEntry),
|
diceInputEntry.SetText(diceInputEntry.Text + ".")
|
||||||
newCustomButton2("^", func() {
|
})
|
||||||
diceInputEntry.SetText(diceInputEntry.Text + "^")
|
powerButton := newCustomButton2("^", func() {
|
||||||
}),
|
diceInputEntry.SetText(diceInputEntry.Text + "^")
|
||||||
newCustomButton2("-", func() {
|
})
|
||||||
diceInputEntry.SetText(diceInputEntry.Text + "-")
|
subtractButton := newCustomButton2("-", func() {
|
||||||
}),
|
diceInputEntry.SetText(diceInputEntry.Text + "-")
|
||||||
rollButton,
|
})
|
||||||
|
|
||||||
|
keypadButtons := []fyne.CanvasObject{
|
||||||
|
lButton, hButton, dxButton, d4Button, d6Button, graphButton,
|
||||||
|
d8Button, d10Button, d12Button, d20Button, d100Button,
|
||||||
|
sevenButton, eightButton, nineButton, multiplyButton, backspaceButton, rollButton,
|
||||||
|
fourButton, fiveButton, sixButton, divideButton, clearButton,
|
||||||
|
oneButton, twoButton, threeButton, addButton, acButton,
|
||||||
|
decimalButton, zeroButton, powerButton, subtractButton,
|
||||||
}
|
}
|
||||||
|
|
||||||
buttonsContainer := container.New(newAspectRatioLayout(3.0/2.0, 7, 5))
|
buttonSpans := map[fyne.CanvasObject]gridSpan{
|
||||||
for _, button := range buttons {
|
lButton: {row: 0, col: 0, rowSpan: 1, colSpan: 1},
|
||||||
|
hButton: {row: 0, col: 1, rowSpan: 1, colSpan: 1},
|
||||||
|
dxButton: {row: 0, col: 2, rowSpan: 1, colSpan: 1},
|
||||||
|
d4Button: {row: 0, col: 3, rowSpan: 1, colSpan: 1},
|
||||||
|
d6Button: {row: 0, col: 4, rowSpan: 1, colSpan: 1},
|
||||||
|
graphButton: {row: 0, col: 5, rowSpan: 2, colSpan: 1},
|
||||||
|
d8Button: {row: 1, col: 0, rowSpan: 1, colSpan: 1},
|
||||||
|
d10Button: {row: 1, col: 1, rowSpan: 1, colSpan: 1},
|
||||||
|
d12Button: {row: 1, col: 2, rowSpan: 1, colSpan: 1},
|
||||||
|
d20Button: {row: 1, col: 3, rowSpan: 1, colSpan: 1},
|
||||||
|
d100Button: {row: 1, col: 4, rowSpan: 1, colSpan: 1},
|
||||||
|
sevenButton: {row: 2, col: 0, rowSpan: 1, colSpan: 1},
|
||||||
|
eightButton: {row: 2, col: 1, rowSpan: 1, colSpan: 1},
|
||||||
|
nineButton: {row: 2, col: 2, rowSpan: 1, colSpan: 1},
|
||||||
|
multiplyButton: {row: 2, col: 3, rowSpan: 1, colSpan: 1},
|
||||||
|
backspaceButton: {row: 2, col: 4, rowSpan: 1, colSpan: 1},
|
||||||
|
rollButton: {row: 2, col: 5, rowSpan: 4, colSpan: 1},
|
||||||
|
fourButton: {row: 3, col: 0, rowSpan: 1, colSpan: 1},
|
||||||
|
fiveButton: {row: 3, col: 1, rowSpan: 1, colSpan: 1},
|
||||||
|
sixButton: {row: 3, col: 2, rowSpan: 1, colSpan: 1},
|
||||||
|
divideButton: {row: 3, col: 3, rowSpan: 1, colSpan: 1},
|
||||||
|
clearButton: {row: 3, col: 4, rowSpan: 1, colSpan: 1},
|
||||||
|
oneButton: {row: 4, col: 0, rowSpan: 1, colSpan: 1},
|
||||||
|
twoButton: {row: 4, col: 1, rowSpan: 1, colSpan: 1},
|
||||||
|
threeButton: {row: 4, col: 2, rowSpan: 1, colSpan: 1},
|
||||||
|
addButton: {row: 4, col: 3, rowSpan: 1, colSpan: 1},
|
||||||
|
acButton: {row: 4, col: 4, rowSpan: 2, colSpan: 1},
|
||||||
|
decimalButton: {row: 5, col: 0, rowSpan: 1, colSpan: 1},
|
||||||
|
zeroButton: {row: 5, col: 1, rowSpan: 1, colSpan: 1},
|
||||||
|
powerButton: {row: 5, col: 2, rowSpan: 1, colSpan: 1},
|
||||||
|
subtractButton: {row: 5, col: 3, rowSpan: 1, colSpan: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonsContainer := container.New(newSpanGridLayout(6, 6, buttonSpans))
|
||||||
|
for _, button := range keypadButtons {
|
||||||
buttonsContainer.Add(button)
|
buttonsContainer.Add(button)
|
||||||
}
|
}
|
||||||
|
|
||||||
topContent := container.NewBorder(
|
topContent := container.NewBorder(
|
||||||
diceInputEntry,
|
diceInputContainer,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
|
|||||||
+427
-269
@@ -3,34 +3,57 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"math/big"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gonum.org/v1/gonum/stat"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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 +61,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 +71,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 +98,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 +115,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 +133,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 +151,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 +175,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 +192,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 +203,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,246 +231,415 @@ 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 {
|
prefixModifier := matches[1]
|
||||||
// It is a dice expression
|
countStr := matches[2]
|
||||||
prefixModifier := matches[1]
|
sidesStr := matches[3]
|
||||||
countStr := matches[2]
|
suffixModifier := matches[4]
|
||||||
sidesStr := matches[3]
|
|
||||||
suffixModifier := matches[4]
|
|
||||||
|
|
||||||
count := 1
|
count := 1
|
||||||
if countStr != "" {
|
if countStr != "" {
|
||||||
c, err := strconv.Atoi(countStr)
|
c, err := strconv.Atoi(countStr)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
count = c
|
|
||||||
}
|
|
||||||
|
|
||||||
sides, err := strconv.Atoi(sidesStr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return distResult{}, err
|
||||||
}
|
}
|
||||||
|
count = c
|
||||||
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)
|
sides, err := strconv.Atoi(sidesStr)
|
||||||
}
|
if err != nil {
|
||||||
|
return distResult{}, err
|
||||||
// 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
|
if count <= 0 || sides <= 0 {
|
||||||
}
|
return distResult{}, fmt.Errorf("dice terms must use positive counts and sides")
|
||||||
|
|
||||||
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 {
|
modifier := ""
|
||||||
res := make(Distribution)
|
if suffixModifier != "" {
|
||||||
for valA, countA := range a {
|
modifier = suffixModifier
|
||||||
for valB, countB := range b {
|
} else if prefixModifier != "" {
|
||||||
res[valA*valB] += countA * countB
|
modifier = prefixModifier
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return res
|
|
||||||
|
return getDiceOutcomes(count, sides, modifier), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func divDist(a, b Distribution) Distribution {
|
func newDistResult(dist Distribution) distResult {
|
||||||
res := make(Distribution)
|
return distResult{
|
||||||
for valA, countA := range a {
|
dist: normalizeDistribution(dist),
|
||||||
for valB, countB := range b {
|
totalOutcomes: big.NewInt(1),
|
||||||
if valB == 0 {
|
}
|
||||||
continue // Division by zero yields no outcome
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
res[valA/valB] += countA * countB
|
}
|
||||||
|
return pruned
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := make(Distribution, len(dist))
|
||||||
|
for value, weight := range dist {
|
||||||
|
probability := weight / total
|
||||||
|
if probability != 0 {
|
||||||
|
normalized[value] = probability
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return res
|
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),
|
||||||
} else {
|
totalOutcomes: multiplyOutcomeCounts(a.totalOutcomes, b.totalOutcomes),
|
||||||
// Integer division for 1/(a^-b) usually 0
|
}
|
||||||
val = int(math.Pow(float64(valA), float64(valB)))
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
res[val] += countA * countB
|
partials[workerIndex] = local
|
||||||
}
|
}(worker, start, end)
|
||||||
}
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
wg.Wait()
|
||||||
|
|
||||||
|
result := make(Distribution)
|
||||||
|
for _, partial := range partials {
|
||||||
|
for value, weight := range partial {
|
||||||
|
result[value] += weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizeDistribution(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateSumOutcomes recursively generates all sums
|
func combineDistributionsFiltered(a, b Distribution, op func(int, int) (int, bool)) Distribution {
|
||||||
func generateSumOutcomes(remaining int, sides int, current []int, outcomes map[int]int) {
|
if len(a) == 0 || len(b) == 0 {
|
||||||
if remaining == 0 {
|
return Distribution{}
|
||||||
sum := 0
|
}
|
||||||
for _, val := range current {
|
|
||||||
sum += val
|
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
|
||||||
}
|
}
|
||||||
outcomes[sum]++
|
}
|
||||||
|
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
|
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
|
||||||
}
|
|
||||||
|
|
||||||
// generateHighestOutcomes recursively generates all highest-die outcomes
|
for worker := 0; worker < workerCount; worker++ {
|
||||||
func generateHighestOutcomes(remaining int, sides int, current []int, outcomes map[int]int) {
|
start := worker * chunkSize
|
||||||
if remaining == 0 {
|
if start >= len(current) {
|
||||||
highest := 0
|
break
|
||||||
for _, val := range current {
|
}
|
||||||
if val > highest {
|
end := start + chunkSize
|
||||||
highest = val
|
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
|
||||||
}
|
}
|
||||||
outcomes[highest]++
|
for i, probability := range partial {
|
||||||
return
|
next[i] += probability
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateAverageAndMedian calculates the average and most common value
|
func highestDieDistribution(count int, sides int) Distribution {
|
||||||
func (s *DiceStatistics) calculateAverageAndMedian() {
|
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() {
|
||||||
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
|
// Prepare data for weighted mean calculation using gonum
|
||||||
maxCount := 0
|
outcomes := make([]float64, 0, len(s.Results))
|
||||||
for value, count := range s.Results {
|
weights := make([]float64, 0, len(s.Results))
|
||||||
if count > maxCount {
|
|
||||||
maxCount = count
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect data for weighted mean calculation
|
||||||
|
outcomes = append(outcomes, float64(value))
|
||||||
|
weights = append(weights, probability)
|
||||||
|
|
||||||
|
// Mode: find outcome with highest 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
|
// Calculate weighted mean using gonum/stat
|
||||||
if maxCount > 0 {
|
s.Average = stat.Mean(outcomes, weights)
|
||||||
for value, count := range s.Results {
|
|
||||||
if count == maxCount && value < s.MostCommon {
|
sort.Ints(s.SortedOutcomes)
|
||||||
s.MostCommon = value
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,706 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCalculateDiceStatistics_Multiplication(t *testing.T) {
|
||||||
|
expression := "5d10*3"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("CalculateDiceStatistics(%q) failed: %v", expression, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For 5d10, min is 5, max is 50.
|
||||||
|
// With *3, min should be 15, max should be 150.
|
||||||
|
if stats.MinValue != 15 {
|
||||||
|
t.Errorf("Expected MinValue 15, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 150 {
|
||||||
|
t.Errorf("Expected MaxValue 150, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateDiceStatistics_Basic(t *testing.T) {
|
||||||
|
// 2d6 -> 2-12
|
||||||
|
expression := "2d6"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
if stats.MinValue != 2 || stats.MaxValue != 12 {
|
||||||
|
t.Errorf("Expected 2-12, got %d-%d", stats.MinValue, stats.MaxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateDiceStatistics_Constant(t *testing.T) {
|
||||||
|
// 5 + 3 -> 8
|
||||||
|
expression := "5+3"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
if stats.MinValue != 8 || stats.MaxValue != 8 {
|
||||||
|
t.Errorf("Expected 8-8, got %d-%d", stats.MinValue, stats.MaxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateDiceStatistics_Mixed_ConstantMult(t *testing.T) {
|
||||||
|
// 1d4 + 2 * 3 -> 1d4 + 6 -> 7-10
|
||||||
|
expression := "1d4+2*3"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
if stats.MinValue != 7 || stats.MaxValue != 10 {
|
||||||
|
t.Errorf("Expected 7-10, got %d-%d", stats.MinValue, stats.MaxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateDiceStatistics_Mixed_DiceMult(t *testing.T) {
|
||||||
|
// 1d4*2 + 3 -> (1..4)*2 + 3 -> {2,4,6,8} + 3 -> {5,7,9,11}
|
||||||
|
expression := "1d4*2+3"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
if stats.MinValue != 5 {
|
||||||
|
t.Errorf("Expected MinValue 5, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 11 {
|
||||||
|
t.Errorf("Expected MaxValue 11, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
// Check that 6 is NOT a possible outcome (since outcomes are 5, 7, 9, 11)
|
||||||
|
if _, exists := stats.Results[6]; exists {
|
||||||
|
t.Errorf("Did not expect outcome 6 to exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateDiceStatistics_ParenthesisAndDiceMult(t *testing.T) {
|
||||||
|
// (5d10+3)*d10
|
||||||
|
// 5d10 ranges 5-50. +3 ranges 8-53.
|
||||||
|
// d10 ranges 1-10.
|
||||||
|
// Min: 8 * 1 = 8.
|
||||||
|
// Max: 53 * 10 = 530.
|
||||||
|
expression := "(5d10+3)*d10"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to calculate %s: %v", expression, err)
|
||||||
|
}
|
||||||
|
if stats.MinValue != 8 {
|
||||||
|
t.Errorf("Expected MinValue 8, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 530 {
|
||||||
|
t.Errorf("Expected MaxValue 530, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateDiceStatistics_ComplexParentheses(t *testing.T) {
|
||||||
|
// 2 * (1d4 + 1) -> 2 * {2,3,4,5} -> {4,6,8,10}
|
||||||
|
expression := "2 * (1d4 + 1)"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
if stats.MinValue != 4 {
|
||||||
|
t.Errorf("Expected MinValue 4, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 10 {
|
||||||
|
t.Errorf("Expected MaxValue 10, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
if _, exists := stats.Results[5]; exists {
|
||||||
|
t.Errorf("Outcome 5 should not exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateDiceStatistics_ImplicitMult_Dice(t *testing.T) {
|
||||||
|
// d203d10 -> 1d203 * 1d10
|
||||||
|
// Min: 1 * 1 = 1
|
||||||
|
// Max: 203 * 10 = 2030
|
||||||
|
expression := "d203d10"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to calculate %s: %v", expression, err)
|
||||||
|
}
|
||||||
|
if stats.MinValue != 1 {
|
||||||
|
t.Errorf("Expected MinValue 1, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 2030 {
|
||||||
|
t.Errorf("Expected MaxValue 2030, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateDiceStatistics_ImplicitMult_ConstantDice(t *testing.T) {
|
||||||
|
// 2d10 -> 2 * d10 -> 2,4,6...20?
|
||||||
|
// NO! 2d10 should be parsed as "2 dice of 10 sides".
|
||||||
|
// The parser MUST prioritize dice notation over implicit multiplication.
|
||||||
|
expression := "2d10"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
// 2d10 is sum of 2 dice. Min 2, Max 20. All values 2..20 possible.
|
||||||
|
if stats.MinValue != 2 {
|
||||||
|
t.Errorf("Expected MinValue 2, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 20 {
|
||||||
|
t.Errorf("Expected MaxValue 20, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
if _, exists := stats.Results[3]; !exists {
|
||||||
|
t.Errorf("Expected outcome 3 to exist for 2d10")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateDiceStatistics_ImplicitMult_NumberDice(t *testing.T) {
|
||||||
|
// 3 d10 -> 3 * d10
|
||||||
|
expression := "3 d10"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
if stats.MinValue != 3 {
|
||||||
|
t.Errorf("Expected MinValue 3, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 30 {
|
||||||
|
t.Errorf("Expected MaxValue 30, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
if _, exists := stats.Results[4]; exists {
|
||||||
|
t.Errorf("Did not expect outcome 4 to exist for 3 * d10")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateDiceStatistics_Division(t *testing.T) {
|
||||||
|
// 1d6 / 2
|
||||||
|
// Outcomes: 1/2=0, 2/2=1, 3/2=1, 4/2=2, 5/2=2, 6/2=3
|
||||||
|
// Expected: 0, 1, 2, 3
|
||||||
|
expression := "1d6/2"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
if stats.MinValue != 0 {
|
||||||
|
t.Errorf("Expected MinValue 0, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 3 {
|
||||||
|
t.Errorf("Expected MaxValue 3, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateDiceStatistics_Power(t *testing.T) {
|
||||||
|
// 1d4 ^ 2
|
||||||
|
// Outcomes: 1, 4, 9, 16
|
||||||
|
expression := "1d4^2"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
if stats.MinValue != 1 {
|
||||||
|
t.Errorf("Expected MinValue 1, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 16 {
|
||||||
|
t.Errorf("Expected MaxValue 16, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
if _, exists := stats.Results[9]; !exists {
|
||||||
|
t.Errorf("Expected outcome 9 to exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateDiceStatistics_Decimal(t *testing.T) {
|
||||||
|
// 2.5 + 2.5 = 2 + 2 = 4 (floor logic)
|
||||||
|
expression := "2.5 + 2.5"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
if stats.MinValue != 4 {
|
||||||
|
t.Errorf("Expected MinValue 4, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_SingleD20 tests a single d20 roll
|
||||||
|
func TestCalculateDiceStatistics_SingleD20(t *testing.T) {
|
||||||
|
stats, err := CalculateDiceStatistics("d20")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
if stats.MinValue != 1 {
|
||||||
|
t.Errorf("Expected MinValue 1, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 20 {
|
||||||
|
t.Errorf("Expected MaxValue 20, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
// All outcomes should be equally likely (1/20 probability)
|
||||||
|
expectedProb := 1.0 / 20.0
|
||||||
|
for value := 1; value <= 20; value++ {
|
||||||
|
prob, exists := stats.Results[value]
|
||||||
|
if !exists {
|
||||||
|
t.Errorf("Expected outcome %d to exist", value)
|
||||||
|
}
|
||||||
|
if prob < expectedProb-0.01 || prob > expectedProb+0.01 {
|
||||||
|
t.Errorf("d20 outcome %d has probability %f, expected %f", value, prob, expectedProb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_AverageValue tests average calculation
|
||||||
|
func TestCalculateDiceStatistics_AverageValue(t *testing.T) {
|
||||||
|
stats, err := CalculateDiceStatistics("1d6")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
// Average of 1d6 should be 3.5
|
||||||
|
expectedAvg := 3.5
|
||||||
|
if stats.Average < expectedAvg-0.1 || stats.Average > expectedAvg+0.1 {
|
||||||
|
t.Errorf("Expected average ~3.5, got %f", stats.Average)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_MostCommonValue tests most common outcome
|
||||||
|
func TestCalculateDiceStatistics_MostCommonValue(t *testing.T) {
|
||||||
|
stats, err := CalculateDiceStatistics("2d6")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
// Most common roll for 2d6 should be 7
|
||||||
|
if stats.MostCommon != 7 {
|
||||||
|
t.Errorf("Expected MostCommon 7, got %d", stats.MostCommon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_SortedOutcomes tests sorted outcomes
|
||||||
|
func TestCalculateDiceStatistics_SortedOutcomes(t *testing.T) {
|
||||||
|
stats, err := CalculateDiceStatistics("1d4")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
expected := []int{1, 2, 3, 4}
|
||||||
|
if len(stats.SortedOutcomes) != len(expected) {
|
||||||
|
t.Errorf("Expected %d outcomes, got %d", len(expected), len(stats.SortedOutcomes))
|
||||||
|
}
|
||||||
|
for i, outcome := range stats.SortedOutcomes {
|
||||||
|
if outcome != expected[i] {
|
||||||
|
t.Errorf("Expected outcome[%d] = %d, got %d", i, expected[i], outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_Percentages tests percentage calculations
|
||||||
|
func TestCalculateDiceStatistics_Percentages(t *testing.T) {
|
||||||
|
stats, err := CalculateDiceStatistics("d20")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
// Each outcome should be 5% (1/20)
|
||||||
|
expectedPercentage := 5.0
|
||||||
|
for value := 1; value <= 20; value++ {
|
||||||
|
pct, exists := stats.Percentages[value]
|
||||||
|
if !exists {
|
||||||
|
t.Errorf("Expected percentage for outcome %d", value)
|
||||||
|
}
|
||||||
|
if pct < expectedPercentage-0.1 || pct > expectedPercentage+0.1 {
|
||||||
|
t.Errorf("d20 outcome %d has percentage %f%%, expected %f%%", value, pct, expectedPercentage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_TotalOutcomes tests total outcomes calculation
|
||||||
|
func TestCalculateDiceStatistics_TotalOutcomes(t *testing.T) {
|
||||||
|
stats, err := CalculateDiceStatistics("2d6")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
// 2d6 should have 6*6 = 36 total outcomes
|
||||||
|
if stats.TotalOutcomes.Int64() != 36 {
|
||||||
|
t.Errorf("Expected 36 total outcomes for 2d6, got %d", stats.TotalOutcomes.Int64())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_Subtraction tests subtraction in statistics
|
||||||
|
func TestCalculateDiceStatistics_Subtraction(t *testing.T) {
|
||||||
|
expression := "1d6-1d4"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to calculate %s: %v", expression, err)
|
||||||
|
}
|
||||||
|
// 1d6 (1-6) - 1d4 (1-4) gives range -3 to 5
|
||||||
|
if stats.MinValue != -3 {
|
||||||
|
t.Errorf("Expected MinValue -3, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 5 {
|
||||||
|
t.Errorf("Expected MaxValue 5, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_MultiplyConstants tests multiplying constants
|
||||||
|
func TestCalculateDiceStatistics_MultiplyConstants(t *testing.T) {
|
||||||
|
expression := "3 * 4"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
if stats.MinValue != 12 || stats.MaxValue != 12 {
|
||||||
|
t.Errorf("Expected 12-12, got %d-%d", stats.MinValue, stats.MaxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_DiceMultiplication tests dice multiplication
|
||||||
|
func TestCalculateDiceStatistics_DiceMultiplication(t *testing.T) {
|
||||||
|
expression := "1d4 * 1d3"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
// Min: 1*1 = 1, Max: 4*3 = 12
|
||||||
|
if stats.MinValue != 1 {
|
||||||
|
t.Errorf("Expected MinValue 1, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 12 {
|
||||||
|
t.Errorf("Expected MaxValue 12, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_HighestModifier tests highest die modifier
|
||||||
|
func TestCalculateDiceStatistics_HighestModifier(t *testing.T) {
|
||||||
|
stats, err := CalculateDiceStatistics("4d6H")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
// 4d6H returns highest die (1-6)
|
||||||
|
if stats.MinValue != 1 {
|
||||||
|
t.Errorf("Expected MinValue 1, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 6 {
|
||||||
|
t.Errorf("Expected MaxValue 6, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_LowestModifier tests lowest die modifier
|
||||||
|
func TestCalculateDiceStatistics_LowestModifier(t *testing.T) {
|
||||||
|
stats, err := CalculateDiceStatistics("4d6L")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
// 4d6L returns lowest die (1-6)
|
||||||
|
if stats.MinValue != 1 {
|
||||||
|
t.Errorf("Expected MinValue 1, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 6 {
|
||||||
|
t.Errorf("Expected MaxValue 6, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_EmptyExpression tests error handling
|
||||||
|
func TestCalculateDiceStatistics_EmptyExpression(t *testing.T) {
|
||||||
|
_, err := CalculateDiceStatistics("")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Empty expression should return error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_InvalidDiceCount tests invalid dice count
|
||||||
|
func TestCalculateDiceStatistics_InvalidDiceCount(t *testing.T) {
|
||||||
|
_, err := CalculateDiceStatistics("0d6")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("0d6 should return error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_InvalidDiceSides tests invalid dice sides
|
||||||
|
func TestCalculateDiceStatistics_InvalidDiceSides(t *testing.T) {
|
||||||
|
_, err := CalculateDiceStatistics("1d0")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("1d0 should return error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_UnexpectedCharacter tests invalid character
|
||||||
|
func TestCalculateDiceStatistics_UnexpectedCharacter(t *testing.T) {
|
||||||
|
_, err := CalculateDiceStatistics("1d6@")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Invalid character should return error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_LargeNumberOfDice tests statistics with many dice
|
||||||
|
func TestCalculateDiceStatistics_LargeNumberOfDice(t *testing.T) {
|
||||||
|
stats, err := CalculateDiceStatistics("10d6")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
// 10d6 ranges from 10 to 60
|
||||||
|
if stats.MinValue != 10 {
|
||||||
|
t.Errorf("Expected MinValue 10, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 60 {
|
||||||
|
t.Errorf("Expected MaxValue 60, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
// Average of 10d6 should be around 35 (10 * 3.5)
|
||||||
|
expectedAvg := 35.0
|
||||||
|
if stats.Average < expectedAvg-1 || stats.Average > expectedAvg+1 {
|
||||||
|
t.Errorf("Expected average ~35, got %f", stats.Average)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_DiceDivision tests dice division
|
||||||
|
func TestCalculateDiceStatistics_DiceDivision(t *testing.T) {
|
||||||
|
expression := "1d10 / 2"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
// 1d10 (1-10) / 2: floor division gives 0-5
|
||||||
|
if stats.MinValue != 0 {
|
||||||
|
t.Errorf("Expected MinValue 0, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 5 {
|
||||||
|
t.Errorf("Expected MaxValue 5, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_NegativeResults tests expressions with negative results
|
||||||
|
func TestCalculateDiceStatistics_NegativeResults(t *testing.T) {
|
||||||
|
expression := "1d4 - 10"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
// 1d4 (1-4) - 10 = -9 to -6
|
||||||
|
if stats.MinValue != -9 {
|
||||||
|
t.Errorf("Expected MinValue -9, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != -6 {
|
||||||
|
t.Errorf("Expected MaxValue -6, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_Parentheses tests parentheses in expressions
|
||||||
|
func TestCalculateDiceStatistics_Parentheses(t *testing.T) {
|
||||||
|
expression := "(1d4 + 2) * 3"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
// (1d4 + 2) * 3 = (3-6) * 3 = 9-18
|
||||||
|
if stats.MinValue != 9 {
|
||||||
|
t.Errorf("Expected MinValue 9, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 18 {
|
||||||
|
t.Errorf("Expected MaxValue 18, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_Exponentiation tests exponentiation in expressions
|
||||||
|
func TestCalculateDiceStatistics_Exponentiation(t *testing.T) {
|
||||||
|
expression := "1d4 ^ 2"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
// 1d4 ^ 2: outcomes 1, 4, 9, 16
|
||||||
|
if stats.MinValue != 1 {
|
||||||
|
t.Errorf("Expected MinValue 1, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 16 {
|
||||||
|
t.Errorf("Expected MaxValue 16, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
// Check that 2 is NOT in results
|
||||||
|
if _, exists := stats.Results[2]; exists {
|
||||||
|
t.Errorf("Outcome 2 should not exist for 1d4^2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_MaxPercentage tests max percentage calculation
|
||||||
|
func TestCalculateDiceStatistics_MaxPercentage(t *testing.T) {
|
||||||
|
stats, err := CalculateDiceStatistics("d6")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
// For d6, all outcomes equally likely: 1/6 ≈ 16.67%
|
||||||
|
expectedMaxPct := (1.0 / 6.0) * 100
|
||||||
|
if stats.MaxPercentage < expectedMaxPct-1 || stats.MaxPercentage > expectedMaxPct+1 {
|
||||||
|
t.Errorf("Expected MaxPercentage ~16.67%%, got %f%%", stats.MaxPercentage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_DistributionSum tests that distribution probabilities sum to 1
|
||||||
|
func TestCalculateDiceStatistics_DistributionSum(t *testing.T) {
|
||||||
|
stats, err := CalculateDiceStatistics("2d6")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
sum := 0.0
|
||||||
|
for _, prob := range stats.Results {
|
||||||
|
sum += prob
|
||||||
|
}
|
||||||
|
// Probabilities should sum to ~1.0
|
||||||
|
if sum < 0.999 || sum > 1.001 {
|
||||||
|
t.Errorf("Probabilities should sum to 1.0, got %f", sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_GetSortedOutcomes tests GetSortedOutcomes method
|
||||||
|
func TestCalculateDiceStatistics_GetSortedOutcomes(t *testing.T) {
|
||||||
|
stats, err := CalculateDiceStatistics("1d6")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
outcomes := stats.GetSortedOutcomes()
|
||||||
|
if len(outcomes) != 6 {
|
||||||
|
t.Errorf("Expected 6 outcomes, got %d", len(outcomes))
|
||||||
|
}
|
||||||
|
// Check they're sorted
|
||||||
|
for i := 1; i < len(outcomes); i++ {
|
||||||
|
if outcomes[i] <= outcomes[i-1] {
|
||||||
|
t.Errorf("Outcomes not properly sorted: %v", outcomes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_GetMaxPercentage tests GetMaxPercentage method
|
||||||
|
func TestCalculateDiceStatistics_GetMaxPercentage(t *testing.T) {
|
||||||
|
stats, err := CalculateDiceStatistics("d20")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
maxPct := stats.GetMaxPercentage()
|
||||||
|
expectedMaxPct := 5.0 // 1/20 = 0.05 = 5%
|
||||||
|
if maxPct < expectedMaxPct-0.1 || maxPct > expectedMaxPct+0.1 {
|
||||||
|
t.Errorf("Expected MaxPercentage 5%%, got %f%%", maxPct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_ComplexExpression tests complex expression
|
||||||
|
func TestCalculateDiceStatistics_ComplexExpression(t *testing.T) {
|
||||||
|
expression := "2d6 + 1d4 + 3"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
// 2d6 (2-12) + 1d4 (1-4) + 3 = 6-19
|
||||||
|
if stats.MinValue != 6 {
|
||||||
|
t.Errorf("Expected MinValue 6, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 19 {
|
||||||
|
t.Errorf("Expected MaxValue 19, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_ThreeDiceSum tests sum of three different dice
|
||||||
|
func TestCalculateDiceStatistics_ThreeDiceSum(t *testing.T) {
|
||||||
|
expression := "1d4 + 1d6 + 1d8"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
// Min: 1+1+1 = 3, Max: 4+6+8 = 18
|
||||||
|
if stats.MinValue != 3 {
|
||||||
|
t.Errorf("Expected MinValue 3, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 18 {
|
||||||
|
t.Errorf("Expected MaxValue 18, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_ImplicitMultiplication tests implicit multiplication
|
||||||
|
func TestCalculateDiceStatistics_ImplicitMultiplication(t *testing.T) {
|
||||||
|
// 2d10 should be treated as 2 dice of 10 sides, not 2 * d10
|
||||||
|
stats, err := CalculateDiceStatistics("2d10")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
if stats.MinValue != 2 {
|
||||||
|
t.Errorf("Expected MinValue 2, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 20 {
|
||||||
|
t.Errorf("Expected MaxValue 20, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_OutcomeAtBoundary tests outcomes at boundaries
|
||||||
|
func TestCalculateDiceStatistics_OutcomeAtBoundary(t *testing.T) {
|
||||||
|
stats, err := CalculateDiceStatistics("1d20")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
// Check minimum boundary
|
||||||
|
if _, exists := stats.Results[1]; !exists {
|
||||||
|
t.Errorf("Expected outcome 1 for d20")
|
||||||
|
}
|
||||||
|
// Check maximum boundary
|
||||||
|
if _, exists := stats.Results[20]; !exists {
|
||||||
|
t.Errorf("Expected outcome 20 for d20")
|
||||||
|
}
|
||||||
|
// Check just outside boundaries
|
||||||
|
if _, exists := stats.Results[0]; exists {
|
||||||
|
t.Errorf("Outcome 0 should not exist for d20")
|
||||||
|
}
|
||||||
|
if _, exists := stats.Results[21]; exists {
|
||||||
|
t.Errorf("Outcome 21 should not exist for d20")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_SortedOutcomesAreUnique tests that sorted outcomes are unique
|
||||||
|
func TestCalculateDiceStatistics_SortedOutcomesAreUnique(t *testing.T) {
|
||||||
|
stats, err := CalculateDiceStatistics("2d6")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
seen := make(map[int]bool)
|
||||||
|
for _, outcome := range stats.SortedOutcomes {
|
||||||
|
if seen[outcome] {
|
||||||
|
t.Errorf("Outcome %d appears more than once in SortedOutcomes", outcome)
|
||||||
|
}
|
||||||
|
seen[outcome] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_AverageInRange tests that average is within min/max
|
||||||
|
func TestCalculateDiceStatistics_AverageInRange(t *testing.T) {
|
||||||
|
stats, err := CalculateDiceStatistics("1d20")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
if stats.Average < float64(stats.MinValue) || stats.Average > float64(stats.MaxValue) {
|
||||||
|
t.Errorf("Average %f should be between min %d and max %d", stats.Average, stats.MinValue, stats.MaxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_MostCommonInResults tests that MostCommon is in Results
|
||||||
|
func TestCalculateDiceStatistics_MostCommonInResults(t *testing.T) {
|
||||||
|
stats, err := CalculateDiceStatistics("2d6")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, exists := stats.Results[stats.MostCommon]; !exists {
|
||||||
|
t.Errorf("MostCommon value %d should exist in Results", stats.MostCommon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_WhitespaceHandling tests whitespace in expressions
|
||||||
|
func TestCalculateDiceStatistics_WhitespaceHandling(t *testing.T) {
|
||||||
|
expression := " 1d6 + 2 "
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
if stats.MinValue != 3 {
|
||||||
|
t.Errorf("Expected MinValue 3, got %d", stats.MinValue)
|
||||||
|
}
|
||||||
|
if stats.MaxValue != 8 {
|
||||||
|
t.Errorf("Expected MaxValue 8, got %d", stats.MaxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateDiceStatistics_ZeroOutcome tests expressions that can produce zero
|
||||||
|
func TestCalculateDiceStatistics_ZeroOutcome(t *testing.T) {
|
||||||
|
expression := "1d4 - 1d4"
|
||||||
|
stats, err := CalculateDiceStatistics(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed: %v", err)
|
||||||
|
}
|
||||||
|
// Should include zero outcome
|
||||||
|
if _, exists := stats.Results[0]; !exists {
|
||||||
|
t.Errorf("Expected outcome 0 for 1d4 - 1d4")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,13 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"math"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/canvas"
|
"fyne.io/fyne/v2/canvas"
|
||||||
"fyne.io/fyne/v2/driver/desktop"
|
"fyne.io/fyne/v2/driver/desktop"
|
||||||
|
"fyne.io/fyne/v2/driver/mobile"
|
||||||
"fyne.io/fyne/v2/theme"
|
"fyne.io/fyne/v2/theme"
|
||||||
"fyne.io/fyne/v2/widget"
|
"fyne.io/fyne/v2/widget"
|
||||||
)
|
)
|
||||||
@@ -80,6 +83,11 @@ type customButton2 struct {
|
|||||||
Text string
|
Text string
|
||||||
OnTapped func()
|
OnTapped func()
|
||||||
Importance widget.Importance
|
Importance widget.Importance
|
||||||
|
TextSize float32
|
||||||
|
Background color.Color
|
||||||
|
Foreground color.Color
|
||||||
|
hovered bool
|
||||||
|
pressed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *customButton2) CreateRenderer() fyne.WidgetRenderer {
|
func (b *customButton2) CreateRenderer() fyne.WidgetRenderer {
|
||||||
@@ -104,14 +112,39 @@ func (b *customButton2) TappedSecondary(*fyne.PointEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *customButton2) MouseIn(*desktop.MouseEvent) {
|
func (b *customButton2) MouseIn(*desktop.MouseEvent) {
|
||||||
|
b.hovered = true
|
||||||
|
b.Refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *customButton2) MouseOut() {
|
func (b *customButton2) MouseOut() {
|
||||||
|
b.hovered = false
|
||||||
|
b.pressed = false
|
||||||
|
b.Refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *customButton2) MouseMoved(*desktop.MouseEvent) {
|
func (b *customButton2) MouseMoved(*desktop.MouseEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *customButton2) MouseDown(*desktop.MouseEvent) {
|
||||||
|
b.pressed = true
|
||||||
|
b.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *customButton2) MouseUp(*desktop.MouseEvent) {
|
||||||
|
b.pressed = false
|
||||||
|
b.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *customButton2) TouchDown(*mobile.TouchEvent) {
|
||||||
|
b.pressed = true
|
||||||
|
b.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *customButton2) TouchUp(*mobile.TouchEvent) {
|
||||||
|
b.pressed = false
|
||||||
|
b.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
type customButton2Renderer struct {
|
type customButton2Renderer struct {
|
||||||
text *canvas.Text
|
text *canvas.Text
|
||||||
background *canvas.Rectangle
|
background *canvas.Rectangle
|
||||||
@@ -122,9 +155,12 @@ type customButton2Renderer struct {
|
|||||||
func (r *customButton2Renderer) Layout(size fyne.Size) {
|
func (r *customButton2Renderer) Layout(size fyne.Size) {
|
||||||
r.background.Resize(size)
|
r.background.Resize(size)
|
||||||
r.text.Resize(size)
|
r.text.Resize(size)
|
||||||
newTextSize := size.Height * 0.4
|
newTextSize := r.button.TextSize
|
||||||
if size.Width < newTextSize {
|
if newTextSize <= 0 {
|
||||||
newTextSize = size.Width * 0.4
|
newTextSize = size.Height * 0.4
|
||||||
|
if size.Width < newTextSize {
|
||||||
|
newTextSize = size.Width * 0.4
|
||||||
|
}
|
||||||
}
|
}
|
||||||
r.text.TextSize = newTextSize
|
r.text.TextSize = newTextSize
|
||||||
}
|
}
|
||||||
@@ -135,6 +171,7 @@ func (r *customButton2Renderer) MinSize() fyne.Size {
|
|||||||
|
|
||||||
func (r *customButton2Renderer) Refresh() {
|
func (r *customButton2Renderer) Refresh() {
|
||||||
r.text.Text = r.button.Text
|
r.text.Text = r.button.Text
|
||||||
|
r.text.TextSize = r.button.TextSize
|
||||||
r.text.Color = r.button.textColor()
|
r.text.Color = r.button.textColor()
|
||||||
r.text.Refresh()
|
r.text.Refresh()
|
||||||
r.background.FillColor = r.button.backgroundColor()
|
r.background.FillColor = r.button.backgroundColor()
|
||||||
@@ -149,6 +186,21 @@ func (r *customButton2Renderer) Destroy() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *customButton2) backgroundColor() color.Color {
|
func (b *customButton2) backgroundColor() color.Color {
|
||||||
|
base := b.baseBackgroundColor()
|
||||||
|
switch {
|
||||||
|
case b.pressed:
|
||||||
|
return adjustColorBrightness(base, 0.82)
|
||||||
|
case b.hovered:
|
||||||
|
return adjustColorBrightness(base, 1.12)
|
||||||
|
default:
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *customButton2) baseBackgroundColor() color.Color {
|
||||||
|
if b.Background != nil {
|
||||||
|
return b.Background
|
||||||
|
}
|
||||||
variant := fyne.CurrentApp().Settings().ThemeVariant()
|
variant := fyne.CurrentApp().Settings().ThemeVariant()
|
||||||
switch b.Importance {
|
switch b.Importance {
|
||||||
case widget.HighImportance:
|
case widget.HighImportance:
|
||||||
@@ -159,6 +211,9 @@ func (b *customButton2) backgroundColor() color.Color {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *customButton2) textColor() color.Color {
|
func (b *customButton2) textColor() color.Color {
|
||||||
|
if b.Foreground != nil {
|
||||||
|
return b.Foreground
|
||||||
|
}
|
||||||
variant := fyne.CurrentApp().Settings().ThemeVariant()
|
variant := fyne.CurrentApp().Settings().ThemeVariant()
|
||||||
switch b.Importance {
|
switch b.Importance {
|
||||||
case widget.HighImportance:
|
case widget.HighImportance:
|
||||||
@@ -187,12 +242,47 @@ func newCustomButton2WithImportance(label string, importance widget.Importance,
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newCustomButton2WithColors(label string, background, foreground color.Color, onTap func()) *customButton2 {
|
||||||
|
b := &customButton2{
|
||||||
|
Text: label,
|
||||||
|
OnTapped: onTap,
|
||||||
|
Background: background,
|
||||||
|
Foreground: foreground,
|
||||||
|
}
|
||||||
|
b.ExtendBaseWidget(b)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func adjustColorBrightness(c color.Color, factor float64) color.Color {
|
||||||
|
r, g, b, a := c.RGBA()
|
||||||
|
return color.NRGBA{
|
||||||
|
R: scaleColorChannel(r, factor),
|
||||||
|
G: scaleColorChannel(g, factor),
|
||||||
|
B: scaleColorChannel(b, factor),
|
||||||
|
A: uint8(a >> 8),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scaleColorChannel(channel uint32, factor float64) uint8 {
|
||||||
|
value := float64(channel>>8) * factor
|
||||||
|
return uint8(math.Max(0, math.Min(255, value)))
|
||||||
|
}
|
||||||
|
|
||||||
// customEntry is a custom entry widget with configurable text size
|
// customEntry is a custom entry widget with configurable text size
|
||||||
type customEntry struct {
|
type customEntry struct {
|
||||||
widget.Entry
|
widget.Entry
|
||||||
TextSize float32
|
TextSize float32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *customEntry) MinSize() fyne.Size {
|
||||||
|
min := e.Entry.MinSize()
|
||||||
|
targetHeight := e.TextSize * 2.2
|
||||||
|
if min.Height < targetHeight {
|
||||||
|
min.Height = targetHeight
|
||||||
|
}
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
|
||||||
func (e *customEntry) CreateRenderer() fyne.WidgetRenderer {
|
func (e *customEntry) CreateRenderer() fyne.WidgetRenderer {
|
||||||
// Call the parent's CreateRenderer to ensure proper initialization
|
// Call the parent's CreateRenderer to ensure proper initialization
|
||||||
renderer := e.Entry.CreateRenderer()
|
renderer := e.Entry.CreateRenderer()
|
||||||
@@ -210,12 +300,6 @@ type customEntryRenderer struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *customEntryRenderer) Layout(size fyne.Size) {
|
func (r *customEntryRenderer) Layout(size fyne.Size) {
|
||||||
// Update TextSize on all canvas text objects
|
|
||||||
for _, obj := range r.parentRenderer.Objects() {
|
|
||||||
if text, ok := obj.(*canvas.Text); ok {
|
|
||||||
text.TextSize = r.entry.TextSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r.parentRenderer.Layout(size)
|
r.parentRenderer.Layout(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,12 +308,6 @@ func (r *customEntryRenderer) MinSize() fyne.Size {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *customEntryRenderer) Refresh() {
|
func (r *customEntryRenderer) Refresh() {
|
||||||
// Update TextSize on all canvas text objects
|
|
||||||
for _, obj := range r.parentRenderer.Objects() {
|
|
||||||
if text, ok := obj.(*canvas.Text); ok {
|
|
||||||
text.TextSize = r.entry.TextSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r.parentRenderer.Refresh()
|
r.parentRenderer.Refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,3 +380,122 @@ func (a *aspectRatioLayout) Layout(objects []fyne.CanvasObject, size fyne.Size)
|
|||||||
func (a *aspectRatioLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
|
func (a *aspectRatioLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
|
||||||
return fyne.NewSize(10, 10)
|
return fyne.NewSize(10, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type gridSpan struct {
|
||||||
|
row int
|
||||||
|
col int
|
||||||
|
rowSpan int
|
||||||
|
colSpan int
|
||||||
|
}
|
||||||
|
|
||||||
|
type spanGridLayout struct {
|
||||||
|
rows int
|
||||||
|
cols int
|
||||||
|
spans map[fyne.CanvasObject]gridSpan
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSpanGridLayout(rows, cols int, spans map[fyne.CanvasObject]gridSpan) fyne.Layout {
|
||||||
|
return &spanGridLayout{
|
||||||
|
rows: rows,
|
||||||
|
cols: cols,
|
||||||
|
spans: spans,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spanGridLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
|
||||||
|
if s.rows <= 0 || s.cols <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cellWidth := size.Width / float32(s.cols)
|
||||||
|
cellHeight := size.Height / float32(s.rows)
|
||||||
|
uniformTextSize := float32(0)
|
||||||
|
|
||||||
|
for _, o := range objects {
|
||||||
|
button, ok := o.(*customButton2)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
span, ok := s.spans[o]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rowSpan := max(span.rowSpan, 1)
|
||||||
|
colSpan := max(span.colSpan, 1)
|
||||||
|
buttonSize := fyne.NewSize(float32(colSpan)*cellWidth, float32(rowSpan)*cellHeight)
|
||||||
|
candidateSize := maxUniformButtonTextSize(button.Text, buttonSize)
|
||||||
|
if candidateSize <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if uniformTextSize == 0 || candidateSize < uniformTextSize {
|
||||||
|
uniformTextSize = candidateSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, o := range objects {
|
||||||
|
if button, ok := o.(*customButton2); ok {
|
||||||
|
button.TextSize = uniformTextSize
|
||||||
|
}
|
||||||
|
|
||||||
|
span, ok := s.spans[o]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rowSpan := max(span.rowSpan, 1)
|
||||||
|
colSpan := max(span.colSpan, 1)
|
||||||
|
|
||||||
|
x := float32(span.col) * cellWidth
|
||||||
|
y := float32(span.row) * cellHeight
|
||||||
|
width := float32(colSpan) * cellWidth
|
||||||
|
height := float32(rowSpan) * cellHeight
|
||||||
|
|
||||||
|
o.Move(fyne.NewPos(x, y))
|
||||||
|
o.Resize(fyne.NewSize(width, height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spanGridLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
|
||||||
|
return fyne.NewSize(10, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxUniformButtonTextSize(label string, size fyne.Size) float32 {
|
||||||
|
const (
|
||||||
|
horizontalPaddingFactor = 0.75
|
||||||
|
heightFactor = 0.42
|
||||||
|
avgCharWidthFactor = 0.62
|
||||||
|
)
|
||||||
|
|
||||||
|
maxByHeight := size.Height * heightFactor
|
||||||
|
charCount := utf8.RuneCountInString(label)
|
||||||
|
if charCount <= 0 {
|
||||||
|
return maxByHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
maxByWidth := (size.Width * horizontalPaddingFactor) / (float32(charCount) * avgCharWidthFactor)
|
||||||
|
if maxByWidth < maxByHeight {
|
||||||
|
return maxByWidth
|
||||||
|
}
|
||||||
|
return maxByHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
type sizeOverrideTheme struct {
|
||||||
|
fyne.Theme
|
||||||
|
textSize float32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *sizeOverrideTheme) Size(name fyne.ThemeSizeName) float32 {
|
||||||
|
if name == theme.SizeNameText {
|
||||||
|
return t.textSize
|
||||||
|
}
|
||||||
|
return t.Theme.Size(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSizeOverrideTheme(base fyne.Theme, textSize float32) fyne.Theme {
|
||||||
|
return &sizeOverrideTheme{
|
||||||
|
Theme: base,
|
||||||
|
textSize: textSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user