aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/plinko.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino/plinko.go')
-rw-r--r--internal/casino/plinko.go395
1 files changed, 395 insertions, 0 deletions
diff --git a/internal/casino/plinko.go b/internal/casino/plinko.go
new file mode 100644
index 0000000..d73a0a4
--- /dev/null
+++ b/internal/casino/plinko.go
@@ -0,0 +1,395 @@
+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
+}