package casino import ( "fmt" "math" "math/rand" "strconv" "strings" "sync" ) const plinkoRows = 16 const plinkoFirstRow = 0 // RTP is the base percentage before pocket multipliers round up to tenths. // 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"} // 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 { 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 } scale := (rtp/100 - 2*outer/65536) / sum for k := 1; k < 16; k++ { out[k] = math.Ceil(out[k]*scale*10) / 10 } return out } func absPlinko(n int) int { if n < 0 { return -n } return n } 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; includes upward rounding to 0.1x" 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) } type PlinkoSnapshot struct { // Row 0 is the apex (preceded by the payout legend); 16 is the pockets. Row, Total int Counts [17]int Pockets [17]float64 Paytable bool Risk string } 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], 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.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.playerAction(n, n+" steps up to Plinko.", "Welcome to PLINKO! Choose your risk and let it rain.") 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 = plinkoFirstRow 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.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++ if t.row > 0 { 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 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])) } 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, "") outcome := "loss" if paid == t.betSize*t.balls { outcome = "push" } else if paid > t.betSize*t.balls { outcome = "win" } 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())) 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 }