diff options
| author | historia <[not public]> | 2026-10-04 17:44:27 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-04 17:44:27 -0400 |
| commit | 93dcb67ffee05ebf1de0c9a78a8b906eaf161007 (patch) | |
| tree | 88cdd3d732f11eee616698a9ca32f699d2b41d77 /internal/casino/plinko.go | |
| parent | 3a39a020e4449bc4d6f728ef837cffb9f7bd0c56 (diff) | |
| download | thehouseoficarus-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.go | 173 |
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 { |
