aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/plinko.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-04 17:44:27 -0400
committerhistoria <[not public]>2026-10-04 17:44:27 -0400
commit93dcb67ffee05ebf1de0c9a78a8b906eaf161007 (patch)
tree88cdd3d732f11eee616698a9ca32f699d2b41d77 /internal/casino/plinko.go
parent3a39a020e4449bc4d6f728ef837cffb9f7bd0c56 (diff)
downloadthehouseoficarus-93dcb67ffee05ebf1de0c9a78a8b906eaf161007.tar.gz
feat: new casino game: cash hunt, fixed verb issue with pai gow
Diffstat (limited to 'internal/casino/plinko.go')
-rw-r--r--internal/casino/plinko.go173
1 files changed, 139 insertions, 34 deletions
diff --git a/internal/casino/plinko.go b/internal/casino/plinko.go
index d73a0a4..412b72c 100644
--- a/internal/casino/plinko.go
+++ b/internal/casino/plinko.go
@@ -4,6 +4,7 @@ import (
"fmt"
"math"
"math/rand"
+ "sort"
"strconv"
"strings"
"sync"
@@ -11,9 +12,7 @@ import (
const plinkoRows = 16
-const plinkoFirstRow = 0
-
-// RTP is the base percentage before pocket multipliers round up to tenths.
+// RTP is the target return percentage for the one-decimal paytables.
// Fixed expert edge prizes alone return 30.517578125%.
type PlinkoConfig struct {
RTP float64 `yaml:"rtp,omitempty"`
@@ -41,10 +40,23 @@ func validatePlinko(c TableConfig) error {
var plinkoRisks = []string{"easy", "medium", "hard", "expert"}
+// Balance in difficulty order so higher risk never reduces actual RTP.
+// Compare exact path-weighted tenths, avoiding floating-point ordering ties.
+func plinkoPockets(risk int, rtp float64) [17]float64 {
+ var pockets [17]float64
+ minimum := 0
+ for difficulty := 0; difficulty <= risk; difficulty++ {
+ pockets, minimum = plinkoBalancedPockets(difficulty, rtp, minimum)
+ }
+ return pockets
+}
+
// Every path has probability 2^-16. Normalize interior weights against the
// binomial distribution, keeping both edge jackpots fixed at every RTP.
-// Round the played multipliers UP to tenths, intentionally raising actual RTP.
-func plinkoPockets(risk int, rtp float64) [17]float64 {
+// Choose floor/ceil tenths jointly, rather than rounding every pocket upward:
+// all 256 symmetric rounding combinations are cheap to check, and the closest
+// monotone paytable tracks the target without inflating the actual return.
+func plinkoBalancedPockets(risk int, rtp float64, minimum int) ([17]float64, int) {
var out [17]float64
outer := []float64{16, 100, 1000, 10000}[risk]
powers := []float64{2, 4, 7, 12}
@@ -55,11 +67,45 @@ func plinkoPockets(risk int, rtp float64) [17]float64 {
out[k] = math.Pow(float64(absPlinko(k-8)+1), powers[risk])
sum += choose * out[k] / 65536
}
- scale := (rtp/100 - 2*outer/65536) / sum
- for k := 1; k < 16; k++ {
- out[k] = math.Ceil(out[k]*scale*10) / 10
+ // If rounding raised a lower risk's return, center this risk's candidates
+ // on that return rather than searching below its required floor.
+ scale := (max(rtp/100, float64(minimum)/655360) - 2*outer/65536) / sum
+ var low, high [9]int
+ for k := 1; k <= 8; k++ {
+ v := out[k] * scale * 10
+ low[k], high[k] = int(math.Floor(v)), int(math.Ceil(v))
+ }
+ // Number of paths to each pocket, mirrored about the center.
+ paths := [9]int{1, 16, 120, 560, 1820, 4368, 8008, 11440, 12870}
+ target := rtp / 100 * 655360 // expected return in path-weighted tenths
+ bestError := math.Inf(1)
+ var best [17]float64
+ bestWeighted := 0
+ for mask := 0; mask < 256; mask++ {
+ candidate := [17]float64{0: outer, 16: outer}
+ weighted := int(outer) * 20
+ valid := true
+ for k := 1; k <= 8; k++ {
+ v := low[k]
+ if mask&(1<<(k-1)) != 0 {
+ v = high[k]
+ }
+ candidate[k], candidate[16-k] = float64(v)/10, float64(v)/10
+ if candidate[k] > candidate[k-1] {
+ valid = false
+ break
+ }
+ copies := 2
+ if k == 8 {
+ copies = 1
+ }
+ weighted += copies * paths[k] * v
+ }
+ if err := math.Abs(float64(weighted) - target); valid && weighted >= minimum && err < bestError {
+ best, bestError, bestWeighted = candidate, err, weighted
+ }
}
- return out
+ return best, bestWeighted
}
func absPlinko(n int) int {
if n < 0 {
@@ -67,6 +113,42 @@ func absPlinko(n int) int {
}
return n
}
+
+// plinkoMultiplierText renders a played multiplier with at most one decimal.
+func plinkoMultiplierText(multiplier float64) string {
+ return strconv.FormatFloat(multiplier, 'f', -1, 64) + "x"
+}
+
+// plinkoReturns splits the actual payout across the distinct landed pockets.
+// Each pocket's exact return is floored; leftover credits go to the largest
+// fractional remainders, so the itemized receipt always sums to the payout.
+func plinkoReturns(counts [17]int, betSize int, pockets [17]float64, paid int) map[int]int {
+ type share struct {
+ pocket, floor int
+ frac float64
+ }
+ var shares []share
+ base := 0
+ for p, count := range counts {
+ if count == 0 {
+ continue
+ }
+ exact := float64(betSize) * pockets[p] * float64(count)
+ floor := int(math.Floor(exact))
+ shares = append(shares, share{pocket: p, floor: floor, frac: exact - float64(floor)})
+ base += floor
+ }
+ sort.SliceStable(shares, func(i, j int) bool { return shares[i].frac > shares[j].frac })
+ for i := 0; i < paid-base && i < len(shares); i++ {
+ shares[i].floor++
+ }
+ returns := make(map[int]int, len(shares))
+ for _, s := range shares {
+ returns[s.pocket] = s.floor
+ }
+ return returns
+}
+
func plinkoReturn(risk int, rtp float64) float64 {
pockets := plinkoPockets(risk, rtp)
choose, result := 1.0, 0.0
@@ -84,21 +166,22 @@ func plinkoHouseEdge(c TableConfig, _ string) HouseEdge {
for risk, name := range plinkoRisks {
e.Parts = append(e.Parts, EdgePart{Label: name, Loss: 1 - plinkoReturn(risk, c.Plinko.Normalize().RTP)})
}
- e.Note = "easy / medium / hard / expert; includes upward rounding to 0.1x"
+ e.Note = "easy / medium / hard / expert; actual returns from one-decimal paytables"
return e
}
func plinkoRules(c TableConfig) string {
- return fmt.Sprintf("Plinko: 16 fair left/right bounces; 17 pockets. Base RTP %.4g%%. Multipliers round UP to 0.1x, increasing actual RTP by risk.\nUse difficulty easy|medium|hard|expert, balls 1-10, then bet N (per ball).\nPocket multipliers INCLUDE your stake. Fractional total returns round randomly, without bias.\nEasy edges pay 16x; expert edges pay 10000x. Higher risk means higher variance.\nOne ball is white; overlapping balls shade toward red (all balls together).\nLeaving is blocked during a drop; disconnect forfeits an unfinished drop.", c.Plinko.Normalize().RTP)
+ return fmt.Sprintf("Plinko: 16 fair left/right bounces; 17 pockets. Target RTP %.4g%%. One-decimal payouts are balanced to track that target.\nUse difficulty easy|medium|hard|expert, balls 1-10, then bet N (per ball).\nPocket multipliers INCLUDE your stake. Fractional total returns round randomly, without bias.\nEasy edges pay 16x; expert edges pay 10000x. Higher risk means higher variance.\nOne ball is white; overlapping balls shade toward red (all balls together).\nLeaving is blocked during a drop; disconnect forfeits an unfinished drop.", c.Plinko.Normalize().RTP)
}
type PlinkoSnapshot struct {
- // Row 0 is the apex (preceded by the payout legend); 16 is the pockets.
+ // Row 0 is the apex; 16 is the pockets. The legend runs alongside the board.
Row, Total int
Counts [17]int
Pockets [17]float64
Paytable bool
Risk string
+ RTP float64 // actual return percentage of Pockets
}
func (*PlinkoSnapshot) snapshot() {}
@@ -180,14 +263,14 @@ func (t *plinkoTable) hints(n string) []MenuCommand {
return t.hintsLocked()
}
func (t *plinkoTable) snap(paytable bool) *PlinkoSnapshot {
- s := &PlinkoSnapshot{Row: t.row, Total: t.balls, Pockets: plinkoPockets(t.risk, t.Config.Plinko.Normalize().RTP), Risk: plinkoRisks[t.risk], Paytable: paytable}
+ s := &PlinkoSnapshot{Row: t.row, Total: t.balls, Pockets: plinkoPockets(t.risk, t.Config.Plinko.Normalize().RTP), Risk: plinkoRisks[t.risk], RTP: 100 * plinkoReturn(t.risk, t.Config.Plinko.Normalize().RTP), Paytable: paytable}
for _, p := range t.positions {
s.Counts[p]++
}
return s
}
func (t *plinkoTable) statusLocked(n string) []Event {
- e := t.private(n, fmt.Sprintf("PLINKO / %s / %d balls / %.2f%% RTP (base %.4g%%)", plinkoRisks[t.risk], t.balls, 100*plinkoReturn(t.risk, t.Config.Plinko.Normalize().RTP), t.Config.Plinko.Normalize().RTP))
+ e := t.private(n, "")
e.Color = "casino_menu"
e.Snapshot = t.snap(true)
if n == t.name {
@@ -210,7 +293,7 @@ func (t *plinkoTable) join(n string) []Event {
t.name = n
t.risk = 0
t.balls = 1
- e := t.playerAction(n, n+" steps up to Plinko.", "Welcome to PLINKO! Choose your risk and let it rain.")
+ e := t.private(n, "")
e.Type = EventJoined
e.Player = n
return append([]Event{e}, t.statusLocked(n)...)
@@ -304,10 +387,10 @@ func (t *plinkoTable) bet(n, spot string, amount int, w Wallet) []Event {
t.wallet = w
t.stake = stake
t.betSize = amount
- t.row = plinkoFirstRow
+ t.row = 0
t.active = true
t.positions = make([]int, t.balls)
- e := t.private(n, fmt.Sprintf("*** PLINKO / %s *** %d balls x %d = %d credits. Let it rain!", strings.ToUpper(plinkoRisks[t.risk]), t.balls, amount, stake.Total))
+ e := t.private(n, fmt.Sprintf("Stake: %d x %d = %d credits.", t.balls, amount, stake.Total))
e.Snapshot = t.snap(false)
return []Event{e}
}
@@ -318,10 +401,8 @@ func (t *plinkoTable) tick() []Event {
return nil
}
t.row++
- if t.row > 0 {
- for i := range t.positions {
- t.positions[i] += t.rng.Intn(2)
- }
+ for i := range t.positions {
+ t.positions[i] += t.rng.Intn(2)
}
e := t.private(t.name, "")
e.Snapshot = t.snap(false)
@@ -333,11 +414,10 @@ func (t *plinkoTable) tick() []Event {
pockets := plinkoPockets(t.risk, t.Config.Plinko.Normalize().RTP)
labels := PlinkoPocketLabels(pockets)
total := 0.0
- var hits []string
- for i, p := range t.positions {
- v := pockets[p] * float64(t.betSize)
- total += v
- hits = append(hits, fmt.Sprintf("Ball %d -> [%c] %.1fx", i+1, labels[p], pockets[p]))
+ var counts [17]int
+ for _, p := range t.positions {
+ counts[p]++
+ total += pockets[p] * float64(t.betSize)
}
paid := int(math.Floor(total))
if t.rng.Float64() < total-float64(paid) {
@@ -348,19 +428,44 @@ func (t *plinkoTable) tick() []Event {
}
t.stake = Wager{}
result := t.private(t.name, "")
- outcome := "loss"
- if paid == t.betSize*t.balls {
- outcome = "push"
- } else if paid > t.betSize*t.balls {
- outcome = "win"
+ // Itemize the receipt by landed payout so each distinct multiplier is a
+ // visible row, instead of a separate "Ball N -> [X]" line per ball.
+ returns := plinkoReturns(counts, t.betSize, pockets, paid)
+ type payoutRow struct {
+ multiplier float64
+ stake, returned int
+ }
+ rows := make(map[rune]*payoutRow)
+ var order []rune
+ for p, count := range counts {
+ if count == 0 {
+ continue
+ }
+ row, ok := rows[labels[p]]
+ if !ok {
+ row = &payoutRow{multiplier: pockets[p]}
+ rows[labels[p]] = row
+ order = append(order, labels[p])
+ }
+ row.stake += t.betSize * count
+ row.returned += returns[p]
+ }
+ result.Settlement = make([]SettlementLine, 0, len(order))
+ for _, label := range order {
+ row := rows[label]
+ result.Settlement = append(result.Settlement, SettlementLine{
+ Label: fmt.Sprintf("[%c] %s", label, plinkoMultiplierText(row.multiplier)),
+ Odds: "Varies",
+ Stake: row.stake,
+ Returned: row.returned,
+ Result: returnResult(row.stake, row.returned),
+ })
}
- result.Settlement = []SettlementLine{{Label: fmt.Sprintf("%d Plinko balls", t.balls), Odds: "Varies", Stake: t.betSize * t.balls, Returned: paid, Result: outcome}}
result.Color = "casino_payout"
if paid > t.betSize*t.balls {
result.Color = "casino_big_win"
}
- // The shared receipt replaces Message, so keep pocket reveals in their own event.
- events = append(events, t.private(t.name, strings.Join(hits, "\n")), result, t.betMenuEvent(t.name, t.hintsLocked()))
+ events = append(events, result, t.betMenuEvent(t.name, t.hintsLocked()))
return events
}
func (t *plinkoTable) markDisconnected(n string) []Event {