From 3a39a020e4449bc4d6f728ef837cffb9f7bd0c56 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sat, 3 Oct 2026 19:46:54 -0400 Subject: feat: new casino game: plinko --- internal/casino/plinko.go | 395 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 internal/casino/plinko.go (limited to 'internal/casino/plinko.go') 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 +} -- cgit v1.2.3