diff options
Diffstat (limited to 'internal/casino')
| -rw-r--r-- | internal/casino/house_edge.go | 7 | ||||
| -rw-r--r-- | internal/casino/plinko.go | 395 | ||||
| -rw-r--r-- | internal/casino/plinko_test.go | 218 | ||||
| -rw-r--r-- | internal/casino/registry.go | 10 | ||||
| -rw-r--r-- | internal/casino/types.go | 2 |
5 files changed, 632 insertions, 0 deletions
diff --git a/internal/casino/house_edge.go b/internal/casino/house_edge.go index 01fe7aa..e175e24 100644 --- a/internal/casino/house_edge.go +++ b/internal/casino/house_edge.go @@ -59,6 +59,13 @@ func (c TableConfig) HouseEdge(bet string) HouseEdge { // HouseEdge reads seat-specific settings under the game's lock. It does not // alter game state, advance RNGs, or reveal cards from the current shoe. func (t *Table) HouseEdge(name, bet string) HouseEdge { + if p, ok := t.game.(*plinkoTable); ok { + p.mu.Lock() + defer p.mu.Unlock() + if p.name == name { + return edge(1 - plinkoReturn(p.risk, p.Config.Plinko.Normalize().RTP)) + } + } if vp, ok := t.game.(*videoPokerTable); ok { vp.mu.Lock() defer vp.mu.Unlock() 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 +} diff --git a/internal/casino/plinko_test.go b/internal/casino/plinko_test.go new file mode 100644 index 0000000..2b79853 --- /dev/null +++ b/internal/casino/plinko_test.go @@ -0,0 +1,218 @@ +package casino + +import ( + "fmt" + "math" + "math/rand" + "strings" + "testing" +) + +func TestPlinkoRoundedRTPAndVariance(t *testing.T) { + for _, rtp := range []float64{31, 90, 98, 100} { + lastVariance := -1.0 + for risk := 0; risk < 4; risk++ { + p := plinkoPockets(risk, rtp) + choose, mean, second := 1.0, 0.0, 0.0 + for k, v := range p { + if k > 0 { + choose *= float64(17-k) / float64(k) + } + mean += choose * v / 65536 + second += choose * v * v / 65536 + if v < 0 || v != p[16-k] { + t.Fatalf("invalid symmetric pockets %v", p) + } + if k < 8 && v < p[k+1] { + t.Fatalf("pockets must grow toward edges: %v", p) + } + } + if mean < rtp/100 || mean >= rtp/100+0.1 { + t.Fatalf("risk %d rounded RTP %g not within [base, base+0.1)", risk, mean) + } + if math.Abs(mean-plinkoReturn(risk, rtp)) > 1e-12 { + t.Fatal("reported RTP differs from played pockets") + } + for _, v := range p { + if math.Abs(v*10-math.Round(v*10)) > 1e-9 { + t.Fatalf("not a tenth: %g", v) + } + } + variance := second - mean*mean + if variance <= lastVariance { + t.Fatalf("variance did not rise: %g <= %g", variance, lastVariance) + } + lastVariance = variance + if risk == 0 && p[0] != 16 || risk == 3 && p[0] != 10000 { + t.Fatal("wrong jackpot") + } + } + } +} +func newTestPlinko() *plinkoTable { + return newTable(1, TableConfig{ID: "plinko", Game: GamePlinko}, rand.New(rand.NewSource(42))).game.(*plinkoTable) +} +func TestPlinkoDropLifecycle(t *testing.T) { + p := newTestPlinko() + p.join("alice") + p.action("alice", "balls 10") + p.action("alice", "difficulty expert") + w := &testWallet{credits: 1000} + p.bet("alice", "", 10, w) + if len(w.open) != 1 || w.open[0].Total != 100 { + t.Fatal("batch must wager once") + } + p.bet("alice", "", 10, w) + p.action("alice", "balls 2") + p.leave("alice") + if !p.active || p.balls != 10 || len(w.open) != 1 { + t.Fatal("active drop changed") + } + for row := plinkoFirstRow + 1; row <= 16; row++ { + old := append([]int(nil), p.positions...) + events := p.tick() + s := events[0].Snapshot.(*PlinkoSnapshot) + sum := 0 + for _, n := range s.Counts { + sum += n + } + if s.Row != row || sum != 10 { + t.Fatal("wrong animation") + } + for i, pos := range p.positions { + if row <= 0 && pos != old[i] { + t.Fatal("ball moved during label prelude") + } + if pos-old[i] < 0 || pos-old[i] > 1 { + t.Fatal("not a left/right bounce") + } + } + } + if p.active || len(w.open) != 0 { + t.Fatal("unsettled drop") + } + paid := w.paid + p.tick() + p.refundAll() + if w.paid != paid { + t.Fatal("duplicate payout") + } +} +func TestPlinkoRefundAndDisconnect(t *testing.T) { + for _, disconnect := range []bool{false, true} { + p := newTestPlinko() + p.join("alice") + w := &testWallet{credits: 100} + p.bet("alice", "", 10, w) + p.tick() + if disconnect { + p.markDisconnected("alice") + } else { + p.refundAll() + } + if len(w.open) != 0 || !p.idle() { + t.Fatal("stake or seat left open") + } + if !disconnect && w.credits+w.paid != 100 { + t.Fatal("shutdown failed to refund") + } + if disconnect && w.credits != 90 { + t.Fatal("disconnect must forfeit") + } + if len(p.tick()) != 0 { + t.Fatal("discarded drop continued") + } + } +} +func TestPlinkoRoundedPocketsArePaidAndAnalyzed(t *testing.T) { + p := newTestPlinko() + p.join("alice") + p.action("alice", "difficulty expert") + w := &testWallet{credits: 100} + p.bet("alice", "", 10, w) + // Both possible final center pockets round up to 0.1x, not just in the UI. + p.row, p.positions = 15, []int{7} + events := p.tick() + if w.paid != 1 || len(w.open) != 0 { + t.Fatalf("rounded pocket not paid: %+v", w) + } + for _, e := range events { + if strings.Contains(e.Message, "credits returned") { + t.Fatal("duplicate payout prose") + } + } + table := &Table{tableBase: p.tableBase, game: p} + want := 1 - plinkoReturn(3, 98) + if got := table.HouseEdge("alice", "main"); len(got.Parts) != 1 || math.Abs(got.Parts[0].Loss-want) > 1e-12 { + t.Fatalf("seat edge ignores rounded payouts: %+v", got) + } + if got := p.Config.HouseEdge("main"); len(got.Parts) != 4 || math.Abs(got.Parts[3].Loss-want) > 1e-12 { + t.Fatalf("config edge ignores risk: %+v", got) + } +} + +func TestPlinkoSharedPocketLabels(t *testing.T) { + for risk := 0; risk < 4; risk++ { + pockets := plinkoPockets(risk, 98) + labels := PlinkoPocketLabels(pockets) + for i := range pockets { + for j := range pockets { + if (pockets[i] == pockets[j]) != (labels[i] == labels[j]) { + t.Fatalf("risk %d: payout/label mismatch at %d,%d", risk, i, j) + } + } + } + if labels[0] != 'A' || labels[16] != 'A' { + t.Fatal("both edges must be A") + } + } + p := newTestPlinko() + p.join("alice") + w := &testWallet{credits: 100} + p.bet("alice", "", 1, w) + p.row, p.positions = 15, []int{15} + events := p.tick() + labels := PlinkoPocketLabels(plinkoPockets(0, 98)) + want := fmt.Sprintf("Ball 1 -> [%c]", labels[p.positions[0]]) + found := false + for _, e := range events { + found = found || strings.Contains(e.Message, want) + } + if !found { + t.Fatalf("settlement did not use mirrored label %s", want) + } +} + +func TestPlinkoConfig(t *testing.T) { + for _, rtp := range []float64{-1, 30, 101, math.NaN(), math.Inf(1)} { + if (TableConfig{ID: "p", Game: GamePlinko, Plinko: PlinkoConfig{RTP: rtp}}).Validate() == nil { + t.Fatalf("accepted RTP %g", rtp) + } + } + c := TableConfig{ID: "p", Game: GamePlinko} + if err := c.Validate(); err != nil { + t.Fatal(err) + } + if c.Normalize().Plinko.RTP != 98 { + t.Fatal("wrong default") + } + if (TableConfig{ID: "p", Game: GameRoulette, Plinko: PlinkoConfig{RTP: 98}}).Validate() == nil { + t.Fatal("wrong rule block accepted") + } +} +func TestPlinkoFailedPayoutRetainsStake(t *testing.T) { + p := newTestPlinko() + p.join("alice") + w := &failingPayoutWallet{testWallet: testWallet{credits: 100}} + p.bet("alice", "", 10, w) + for i := plinkoFirstRow; i < 16; i++ { + p.tick() + } + if len(w.open) != 1 { + t.Fatal("failed settlement consumed stake") + } + p.leave("alice") + if len(w.open) != 1 { + t.Fatal("leave consumed failed stake") + } +} diff --git a/internal/casino/registry.go b/internal/casino/registry.go index 4366bc6..fdaecec 100644 --- a/internal/casino/registry.go +++ b/internal/casino/registry.go @@ -44,6 +44,16 @@ var tableGames map[GameName]tableGameDef func init() { tableGames = map[GameName]tableGameDef{ + GamePlinko: { + validate: validatePlinko, + normalize: func(c TableConfig) TableConfig { c.Plinko = c.Plinko.Normalize(); c.MaxPlayers = 1; return c }, + ruleBlockName: "plinko", + hasRuleBlock: func(c TableConfig) bool { return c.Plinko != (PlinkoConfig{}) }, + new: newPlinkoTable, + ruleText: plinkoRules, + helpTopic: func(TableConfig) string { return "plinko" }, + houseEdge: plinkoHouseEdge, + }, GamePaiGow: { houseEdge: paiGowHouseEdge, validate: validatePaiGow, diff --git a/internal/casino/types.go b/internal/casino/types.go index 8986dc1..5f4d4f8 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -17,6 +17,7 @@ const ( GamePaiGow GameName = "paigow" GameVideoPoker GameName = "video_poker" GameSlots GameName = "slots" + GamePlinko GameName = "plinko" GameBlackjack GameName = "blackjack" GameBaccarat GameName = "baccarat" GameRoulette GameName = "roulette" @@ -124,6 +125,7 @@ func (c BaccaratConfig) Normalize() BaccaratConfig { } type TableConfig struct { + Plinko PlinkoConfig `yaml:"plinko,omitempty"` ID string `yaml:"id"` Game GameName `yaml:"game"` MaxPlayers int `yaml:"max_players,omitempty"` |
