package casino import ( "fmt" "math" "math/rand" "sort" "strconv" "strings" "sync" ) const plinkoRows = 16 // PlinkoConfig holds the RTP target for the one-decimal paytables. Fixed // expert edge prizes alone return 30.517578125%. type PlinkoConfig struct { RTP float64 `yaml:"rtp,omitempty"` } func (c PlinkoConfig) Normalize() PlinkoConfig { if c.RTP == 0 { c.RTP = 98 } return c } func validatePlinko(c TableConfig) error { r := c.Plinko.Normalize().RTP if math.IsNaN(r) || math.IsInf(r, 0) || r < 31 || r > 100 { return fmt.Errorf("plinko rtp must be between 31 and 100 percent") } if c.MaxPlayers > 1 { return fmt.Errorf("plinko has one seat") } if c.MaxBet > MaxCredits/100000 { return fmt.Errorf("plinko max_bet must not exceed %d", MaxCredits/100000) } return nil } 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. // 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} out[0], out[16] = outer, outer sum, choose := 0.0, 1.0 for k := 1; k < 16; k++ { choose *= float64(17-k) / float64(k) out[k] = math.Pow(float64(absPlinko(k-8)+1), powers[risk]) sum += choose * out[k] / 65536 } // 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 best, bestWeighted } func absPlinko(n int) int { if n < 0 { return -n } 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 for k, multiplier := range pockets { if k > 0 { choose *= float64(17-k) / float64(k) } result += choose * multiplier / 65536 } return result } func plinkoHouseEdge(c TableConfig, _ string) HouseEdge { var e 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; actual returns from one-decimal paytables" return e } func plinkoRules(c TableConfig) string { 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; 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() {} // PlinkoPocketLabels gives every distinct played multiplier one letter, in // outside-to-center order. Mirrored pockets and rounded ties share a label. // Settlement, the legend and the board must use the same mapping. func PlinkoPocketLabels(pockets [17]float64) [17]rune { var labels [17]rune seen := make(map[float64]rune) for k, value := range pockets { label, ok := seen[value] if !ok { label = 'A' + rune(len(seen)) seen[value] = label } labels[k] = label } return labels } type plinkoTable struct { *tableBase mu sync.Mutex rng *rand.Rand name string risk, balls, betSize, row int active bool positions []int wallet Wallet stake Wager } func newPlinkoTable(room int, c TableConfig, rng *rand.Rand) tableGame { return &plinkoTable{tableBase: &tableBase{RoomID: room, Config: c}, rng: rng, balls: 1} } func (t *plinkoTable) base() *tableBase { return t.tableBase } func (t *plinkoTable) idle() bool { t.mu.Lock(); defer t.mu.Unlock(); return t.name == "" } func (t *plinkoTable) hasParticipant(n string) bool { t.mu.Lock() defer t.mu.Unlock() return t.name != "" && t.name == n } func (t *plinkoTable) busy(n string) bool { t.mu.Lock() defer t.mu.Unlock() return t.name == n && t.active } func (t *plinkoTable) overview() string { return "Plinko / 16-row pyramid" } func (t *plinkoTable) seats() []string { t.mu.Lock() defer t.mu.Unlock() if t.name == "" { return nil } return []string{t.name} } func (t *plinkoTable) state() string { t.mu.Lock() defer t.mu.Unlock() if t.active { return "dropping" } return "waiting" } func (t *plinkoTable) wagers(string) []WagerRow { return nil } func (t *plinkoTable) hintsLocked() []MenuCommand { if t.active { return nil } return []MenuCommand{{Command: "bet N", Desc: "Drop balls; N credits PER ball"}, {Command: "balls N", Desc: "Drop 1-10 balls together"}, {Command: "difficulty easy|medium|hard|expert", Desc: "Choose payout variance"}, {Command: "paytable", Desc: "Show all pocket multipliers"}, {Command: "rules", Desc: "Plinko rules"}, {Command: "stop", Desc: "Leave Plinko"}} } func (t *plinkoTable) hints(n string) []MenuCommand { t.mu.Lock() defer t.mu.Unlock() if n != t.name { return nil } 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], 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, "") e.Color = "casino_menu" e.Snapshot = t.snap(true) if n == t.name { e.Menu = &MenuView{ShowBalance: true, Commands: t.hintsLocked()} } return []Event{e} } func (t *plinkoTable) status(n string) []Event { t.mu.Lock() defer t.mu.Unlock() return t.statusLocked(n) } func (t *plinkoTable) board(string) Snapshot { t.mu.Lock(); defer t.mu.Unlock(); return t.snap(true) } func (t *plinkoTable) join(n string) []Event { t.mu.Lock() defer t.mu.Unlock() if t.name != "" { return []Event{t.private(n, "Plinko is occupied.")} } t.name = n t.risk = 0 t.balls = 1 e := t.private(n, "") e.Type = EventJoined e.Player = n return append([]Event{e}, t.statusLocked(n)...) } func (t *plinkoTable) clear() { t.name = "" t.active = false t.positions = nil t.wallet = nil t.stake = Wager{} t.betSize = 0 t.row = 0 } func (t *plinkoTable) leave(n string) []Event { t.mu.Lock() defer t.mu.Unlock() if n != t.name { return nil } if t.active { return []Event{t.private(n, "The balls are still falling!")} } t.clear() e := t.playerAction(n, n+" leaves Plinko.", "You leave Plinko.") e.Type = EventLeft e.Player = n return []Event{e} } func (t *plinkoTable) rebet(n string) (string, int) { t.mu.Lock() defer t.mu.Unlock() if n == t.name { return "", t.betSize } return "", 0 } func (t *plinkoTable) action(n, a string) []Event { t.mu.Lock() defer t.mu.Unlock() if n != t.name { return nil } if t.active { return []Event{t.private(n, "Wait for the balls to land.")} } f := strings.Fields(strings.ToLower(a)) if len(f) == 1 && f[0] == "paytable" { return t.statusLocked(n) } if len(f) == 2 { switch f[0] { case "balls": v, err := strconv.Atoi(f[1]) if err == nil && v >= 1 && v <= 10 { t.balls = v return t.statusLocked(n) } case "difficulty": for i, r := range plinkoRisks { if f[1] == r { t.risk = i return t.statusLocked(n) } } } } return []Event{t.private(n, "Use difficulty easy|medium|hard|expert, balls 1-10, or paytable.")} } func (t *plinkoTable) bet(n, spot string, amount int, w Wallet) []Event { t.mu.Lock() defer t.mu.Unlock() if n != t.name { return nil } if t.active { return []Event{t.private(n, "The balls are still falling!")} } if spot != "" && spot != "main" { return []Event{t.private(n, "Use bet N; N is the stake per ball.")} } if amount < t.Config.MinBet || amount > t.Config.MaxBet || amount > MaxCredits/100000 { return []Event{t.private(n, "Per-ball bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")} } if w == nil { return []Event{t.private(n, "No wallet is available.")} } stake, err := w.Wager(amount * t.balls) if err != nil { return []Event{t.private(n, walletErrorMessage(err, "Not enough chips and credits for all balls."))} } t.wallet = w t.stake = stake t.betSize = amount t.row = 0 t.active = true t.positions = make([]int, t.balls) 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} } func (t *plinkoTable) tick() []Event { t.mu.Lock() defer t.mu.Unlock() if !t.active { return nil } t.row++ for i := range t.positions { t.positions[i] += t.rng.Intn(2) } e := t.private(t.name, "") e.Snapshot = t.snap(false) events := []Event{e} if t.row < plinkoRows { return events } t.active = false pockets := plinkoPockets(t.risk, t.Config.Plinko.Normalize().RTP) labels := PlinkoPocketLabels(pockets) total := 0.0 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) { paid++ } if err := t.wallet.PayCredits(paid, t.stake.ID); err != nil { return append(events, t.private(t.name, payoutFailedMessage())) } t.stake = Wager{} result := t.private(t.name, "") // 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.Color = "casino_payout" if paid > t.betSize*t.balls { result.Color = "casino_big_win" } events = append(events, result, t.betMenuEvent(t.name, t.hintsLocked())) return events } func (t *plinkoTable) markDisconnected(n string) []Event { t.mu.Lock() defer t.mu.Unlock() if n != t.name { return nil } if t.active && t.wallet != nil { t.wallet.ResolveStake(t.stake.ID) } t.clear() e := t.event(n+" leaves Plinko.", true) e.Type = EventLeft e.Player = n return []Event{e} } func (t *plinkoTable) refundAll() []Event { t.mu.Lock() defer t.mu.Unlock() var events []Event if t.wallet != nil { chips, credits, err := t.wallet.RefundOutstanding() msg := fmt.Sprintf("Plinko closes; %d credits/chips refunded.", chips+credits) if err != nil { msg = "Plinko refund could not be persisted; your stake remains pending for recovery." } events = append(events, t.private(t.name, msg)) } t.clear() return events }