diff options
| author | historia <[not public]> | 2026-10-09 12:32:48 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-09 12:32:48 -0400 |
| commit | e95b15ed51832e46c7d0d90f3c84d0ac0b65fbe3 (patch) | |
| tree | 512ee9e1ce9333670553d90efe55e91d8ecff0fd /internal/casino/pachinko.go | |
| parent | 9666676766bf21364f4fc4d0d1d336e72f2a91b4 (diff) | |
| download | thehouseoficarus-e95b15ed51832e46c7d0d90f3c84d0ac0b65fbe3.tar.gz | |
feat: pachinko slots randomized, 9th slot removed, RTP set to 100% if played perfectly
Diffstat (limited to 'internal/casino/pachinko.go')
| -rw-r--r-- | internal/casino/pachinko.go | 785 |
1 files changed, 785 insertions, 0 deletions
diff --git a/internal/casino/pachinko.go b/internal/casino/pachinko.go new file mode 100644 index 0000000..a374d06 --- /dev/null +++ b/internal/casino/pachinko.go @@ -0,0 +1,785 @@ +package casino + +import ( + "fmt" + "math" + "math/rand" + "strconv" + "strings" + "sync" +) + +// Pachinko is Plinko's rectangular cousin: the player picks one of eight top +// slots, a single ball makes pachinkoRows fair 50:50 bounces inside a fixed +// rectangle (the walls reflect), and it lands on one of eight prize pockets. +// The board is populated with fractional prizes and revealed before the player +// chooses, so the best slot changes from board to board. A rare DOUBLE pocket +// doubles every prize and grants a free re-drop, staying on the board so the +// doubling can chain. +const ( + pachinkoLanes = 8 + pachinkoRows = 20 +) + +// Board composition. Seven pockets are i.i.d. "filler" prizes (0.1-1.5x in 0.1 +// steps, heavily weighted toward small returns) and one pocket is a guaranteed +// "premium" above 1.5x. So every board is populated and every board has at +// least one reason to aim. Best-slot play returns 100%; the house edge comes +// only from landing on a worse slot. Premium values at or above 2x are whole +// numbers. +var ( + pachinkoFillers = [4]float64{0.1, 0.5, 1.0, 1.5} + pachinkoFillerWeights = [4]float64{0.30, 0.38, 0.22, 0.10} + pachinkoPremiums = [5]float64{1.7, 2, 5, 10, 100} + // Premium tickets per 10,000 boards. Tuned against the eight-lane + // half-lane ball walk so the best-slot return is exactly 100%; the rare + // tails keep their shape and the 1.7x/2x bulk absorbs the tuning. + pachinkoPremiumWeights = [5]float64{0.41312, 0.53726, 0.03730, 0.01120, 0.00112} +) + +const ( + pachinkoPremiumRoll = 10000 + pachinkoDoubleRoll = 100 + pachinkoDoubleBelow = 5 // 5% of boards carry a DOUBLE pocket + pachinkoMaxDoubling = 40 +) + +// Pachinko phases. +const ( + pachinkoIdle = "idle" + pachinkoChoosing = "choosing" + pachinkoDropping = "dropping" + // pachinkoResult is a transient phase so the settled prize row renders once. + pachinkoResult = "result" +) + +// PachinkoPocket is one prize slot. Value is the total-return multiplier +// (including stake) when Double is false; Double marks the chaining DOUBLE +// space, whose Value is ignored. +type PachinkoPocket struct { + Value float64 `json:"value" yaml:"value"` + Double bool `json:"double" yaml:"double"` +} + +// pachinkoMultiplier scales a pocket value by `doubling` doublings, clamped so +// the displayed and paid value never overflows. +func pachinkoMultiplier(value float64, doubling int) float64 { + if doubling > pachinkoMaxDoubling { + return float64(MaxCredits) + } + for i := 0; i < doubling; i++ { + value *= 2 + if value >= float64(MaxCredits) { + return float64(MaxCredits) + } + } + return value +} + +// pachinkoPocketText renders a pocket after `doubling` doublings. +func pachinkoPocketText(p PachinkoPocket, doubling int) string { + if p.Double { + return "DOUBLE" + } + value := pachinkoMultiplier(p.Value, doubling) + if value >= float64(MaxCredits) { + return "MAX" + } + return strconv.FormatFloat(value, 'f', -1, 64) + "x" +} + +func validatePachinko(c TableConfig) error { + if c.MaxPlayers > 1 { + return fmt.Errorf("pachinko has one seat") + } + return nil +} + +// pachinkoReach returns P[start][pocket], the probability a ball dropped from +// each top slot lands in each pocket after pachinkoRows reflected bounces. +func pachinkoReach() [pachinkoLanes][pachinkoLanes]float64 { + var reach [pachinkoLanes][pachinkoLanes]float64 + positions := 2*pachinkoLanes - 1 // one position per lane plus the gaps + for start := 0; start < pachinkoLanes; start++ { + v := make([]float64, positions) + v[2*start] = 1 + for row := 0; row < pachinkoRows; row++ { + next := make([]float64, positions) + for p, prob := range v { + if prob == 0 { + continue + } + switch p { + case 0: + next[1] += prob + case positions - 1: + next[p-1] += prob + default: + next[p-1] += prob * 0.5 + next[p+1] += prob * 0.5 + } + } + v = next + } + for pocket := 0; pocket < pachinkoLanes; pocket++ { + reach[start][pocket] = v[2*pocket] + } + } + return reach +} + +// pachinkoBoardValue is the expected return of a board when the player always +// drops from the best top slot. If the ball lands on DOUBLE it doubles the +// board and drops again; the value V therefore satisfies +// V = max_s (A_s + 2*q_s*V), where A_s is the numeric prize from slot s and +// q_s its DOUBLE chance. Rearranged, V = max_s A_s/(1-2*q_s). +func pachinkoBoardValue(reach *[pachinkoLanes][pachinkoLanes]float64, pockets *[pachinkoLanes]PachinkoPocket) float64 { + best := 0.0 + for start := 0; start < pachinkoLanes; start++ { + numeric, double := 0.0, 0.0 + for lane := 0; lane < pachinkoLanes; lane++ { + prob := reach[start][lane] + if pockets[lane].Double { + double += prob + } else { + numeric += prob * pockets[lane].Value + } + } + denom := 1 - 2*double + if denom <= 1e-9 { + return math.Inf(1) + } + if value := numeric / denom; value > best { + best = value + } + } + return best +} + +// pachinkoOptimalRTP computes, once, the exact expected return when the player +// always chooses the best top slot. Seven filler pockets are i.i.d. and the +// eighth is the guaranteed premium; DOUBLE replaces a filler pocket. The +// enumeration is over 4^(lanes-1) filler rows and every premium position and +// value. +var pachinkoOptimalRTP = sync.OnceValue(func() float64 { + return pachinkoRTPwith(pachinkoPremiumWeights) +}) + +// pachinkoRTPwith computes the exact expected best-slot return for a given +// distribution over the five premium tiers. It is separated from the cached +// pachinkoOptimalRTP so the premium weights can be solved for numerically and +// the result verified during tuning. +func pachinkoRTPwith(premiumWeights [5]float64) float64 { + reach := pachinkoReach() + const fillers = len(pachinkoFillers) + total := 1 + for i := 0; i < pachinkoLanes-1; i++ { + total *= fillers + } + pDouble := float64(pachinkoDoubleBelow) / pachinkoDoubleRoll + // invDouble precomputes 1/(1-2q) for every slot/pocket pair so the doubling + // term is a multiply in the hot loop rather than a division. + var invDouble [pachinkoLanes][pachinkoLanes]float64 + for s := 0; s < pachinkoLanes; s++ { + for d := 0; d < pachinkoLanes; d++ { + invDouble[s][d] = 1 / (1 - 2*reach[s][d]) + } + } + var f [pachinkoLanes - 1]float64 + var baseA, premContrib [pachinkoLanes]float64 + var rtp float64 + for code := 0; code < total; code++ { + x := code + prob := 1.0 + for i := 0; i < pachinkoLanes-1; i++ { + kind := x % fillers + x /= fillers + f[i] = pachinkoFillers[kind] + prob *= pachinkoFillerWeights[kind] + } + if prob == 0 { + continue + } + // Base contributions with the premium pocket counting zero. The first + // position is built directly; each step moves the premium one lane and + // swaps one filler in and one out. + for s := 0; s < pachinkoLanes; s++ { + sum := 0.0 + for j := 1; j < pachinkoLanes; j++ { + sum += reach[s][j] * f[j-1] + } + baseA[s] = sum + } + for p := 0; p < pachinkoLanes; p++ { + if p > 0 { + fp := f[p-1] + for s := 0; s < pachinkoLanes; s++ { + baseA[s] += reach[s][p-1]*fp - reach[s][p]*fp + } + } + for pi, prem := range pachinkoPremiums { + w := prob * premiumWeights[pi] / pachinkoLanes + if w == 0 { + continue + } + for s := 0; s < pachinkoLanes; s++ { + premContrib[s] = reach[s][p] * prem + } + best := 0.0 + for s := 0; s < pachinkoLanes; s++ { + if value := baseA[s] + premContrib[s]; value > best { + best = value + } + } + rtp += w * (1 - pDouble) * best + if pDouble == 0 { + continue + } + var doubles float64 + for d := 0; d < pachinkoLanes; d++ { + if d == p { + continue + } + fd := 0.0 + if d < p { + fd = f[d] + } else { + fd = f[d-1] + } + best := 0.0 + for s := 0; s < pachinkoLanes; s++ { + q := reach[s][d] + if value := (baseA[s] + premContrib[s] - q*fd) * invDouble[s][d]; value > best { + best = value + } + } + doubles += best + } + rtp += w * pDouble * doubles / float64(pachinkoLanes-1) + } + } + } + return rtp +} + +// pachinkoUniformRTP is the return for a player who ignores the board and +// drops from a uniformly random slot. It averages the pocket mean over the +// composition, removes the value a DOUBLE replaces, then divides by the +// average double-free probability so the DOUBLE chain is priced in. The exact +// best-slot return is far higher; the gap is the skill of reading the board. +var pachinkoUniformRTP = (func() float64 { + meanFiller := 0.0 + for i, value := range pachinkoFillers { + meanFiller += pachinkoFillerWeights[i] * value + } + meanPremium := 0.0 + for i, value := range pachinkoPremiums { + meanPremium += pachinkoPremiumWeights[i] * value + } + pDouble := float64(pachinkoDoubleBelow) / pachinkoDoubleRoll + sum := float64(pachinkoLanes-1)*meanFiller + meanPremium - pDouble*meanFiller + return (sum / pachinkoLanes) / (1 - 2*pDouble/pachinkoLanes) +})() + +// pachinkoHouseEdge: best-slot play is exactly fair, so the theoretical edge is +// zero. The real edge comes from dropping on a worse slot, which the paytable's +// uniform-slot figure quantifies. +func pachinkoHouseEdge(TableConfig, string) HouseEdge { + return HouseEdge{ + Parts: []EdgePart{{Loss: max(0, 1-pachinkoOptimalRTP())}}, + Note: "best-slot play returns 100%; the edge depends on slot choice", + } +} + +func pachinkoRules(TableConfig) string { + return fmt.Sprintf("Pachinko: a %d-slot rectangle; one ball per drop makes %d fair 50:50 bounces off the walls.\n"+ + "bet N sets and reveals the board; then drop SLOT (a bare slot number also works) chooses any top slot 1-%d. The board is always revealed before you pick.\n"+ + "Seven pockets are filler prizes (0.1x to 1.5x, mostly 0.5x and below); the eighth is a guaranteed premium above 1.5x: usually 1.7x or 2x, occasionally 5x or 10x, and about 1 board in 900 carries the 100x.\n"+ + "Multipliers INCLUDE your stake. DOUBLE doubles every prize on the board and re-drops the ball for free, staying in place so it can double again and again.\n"+ + "The best slot changes with every board. Return assumes you always choose it: about %.2f%%; dropping from a random slot returns about %.2f%%.\n"+ + "You cannot leave or change a committed board. Shutdown refunds it, disconnect forfeits an unfinished drop, and a failed payout keeps its stake pending.", + pachinkoLanes, pachinkoRows, pachinkoLanes, 100*pachinkoOptimalRTP(), 100*pachinkoUniformRTP) +} + +type PachinkoPrizeOdds struct { + Label string + Odds string // qualitative wording shown to every player + Chance float64 // probability per board; 0 when no single chance applies +} + +type PachinkoSnapshot struct { + Phase string + Pockets [pachinkoLanes]PachinkoPocket + Doubling int + Row int // 0 at the chosen slot, pachinkoRows at the pockets + Lane int // 0-based lane of the ball + Slot int // 1-based chosen slot; 0 when unchosen + Landing int // 0-based landed pocket; -1 when not landed + RTP float64 + Uniform float64 + Odds []PachinkoPrizeOdds // populated for the idle/paytable views +} + +func (*PachinkoSnapshot) snapshot() {} + +// PachinkoOdds summarises the board composition in probability order. Chance +// is a fraction of boards; the presentation layer shows it only to players +// carrying a House Edge instrument. +func PachinkoOdds() []PachinkoPrizeOdds { + double := float64(pachinkoDoubleBelow) / pachinkoDoubleRoll + return []PachinkoPrizeOdds{ + {Label: "0.1-1.5x", Odds: "7 filler pockets"}, + {Label: "1.7x", Odds: "floors", Chance: pachinkoPremiumWeights[0]}, + {Label: "2x", Odds: "floors", Chance: pachinkoPremiumWeights[1]}, + {Label: "5x", Odds: "floors", Chance: pachinkoPremiumWeights[2]}, + {Label: "10x", Odds: "floors", Chance: pachinkoPremiumWeights[3]}, + {Label: "100x", Odds: "floors", Chance: pachinkoPremiumWeights[4]}, + {Label: "DOUBLE", Odds: "per board", Chance: double}, + } +} + +type pachinkoTable struct { + *tableBase + mu sync.Mutex + rng *rand.Rand + name string + betSize int + wallet Wallet + stake Wager + phase string + pockets [pachinkoLanes]PachinkoPocket + doubling int + row int + pos int // half-lane position 0..2*pachinkoLanes-2; even on a pocket + lane int // display lane, pos/2 + slot int + landing int +} + +func newPachinkoTable(room int, c TableConfig, rng *rand.Rand) tableGame { + return &pachinkoTable{tableBase: &tableBase{RoomID: room, Config: c}, rng: rng, phase: pachinkoIdle, landing: -1} +} +func (t *pachinkoTable) base() *tableBase { return t.tableBase } +func (t *pachinkoTable) idle() bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.name == "" +} +func (t *pachinkoTable) hasParticipant(n string) bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.name != "" && t.name == n +} +func (t *pachinkoTable) busy(n string) bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.name == n && t.phase == pachinkoDropping +} +func (t *pachinkoTable) overview() string { return "Pachinko / 8-slot rectangle" } +func (t *pachinkoTable) seats() []string { + t.mu.Lock() + defer t.mu.Unlock() + if t.name == "" { + return nil + } + return []string{t.name} +} +func (t *pachinkoTable) state() string { + t.mu.Lock() + defer t.mu.Unlock() + switch t.phase { + case pachinkoDropping: + return "dropping" + case pachinkoChoosing: + return "choosing a slot" + default: + return "waiting" + } +} +func (t *pachinkoTable) wagers(string) []WagerRow { return nil } + +func (t *pachinkoTable) hintsLocked() []MenuCommand { + switch t.phase { + case pachinkoDropping: + return nil + case pachinkoChoosing: + return []MenuCommand{ + {Command: "drop N", Desc: "Drop from top slot 1-9; a bare number also works"}, + {Command: "table", Desc: "Show the blank board and pay table"}, + {Command: "rules", Desc: "Pachinko rules"}, + } + default: + return []MenuCommand{ + {Command: "bet N", Desc: "Set and reveal the board"}, + {Command: "table", Desc: "Show the blank board and pay table"}, + {Command: "rules", Desc: "Pachinko rules"}, + {Command: "stop", Desc: "Leave Pachinko"}, + } + } +} +func (t *pachinkoTable) hints(n string) []MenuCommand { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + return t.hintsLocked() +} +func (t *pachinkoTable) snapLocked(phase string) *PachinkoSnapshot { + s := &PachinkoSnapshot{ + Phase: phase, + Pockets: t.pockets, + Doubling: t.doubling, + Row: t.row, + Lane: t.lane, + Slot: t.slot, + Landing: t.landing, + RTP: 100 * pachinkoOptimalRTP(), + Uniform: 100 * pachinkoUniformRTP, + } + if phase == pachinkoIdle || phase == "paytable" { + s.Odds = PachinkoOdds() + } + return s +} +func (t *pachinkoTable) phaseLocked() string { + if t.phase == "" { + return pachinkoIdle + } + return t.phase +} + +// board is the table command's view: the reference blank board and pay table, +// never the live drop, so the command always answers "what can I win and how". +func (t *pachinkoTable) board(string) Snapshot { + t.mu.Lock() + defer t.mu.Unlock() + return t.snapLocked("paytable") +} +func (t *pachinkoTable) statusLocked(n string) []Event { + e := t.private(n, "") + e.Color = "casino_menu" + e.Snapshot = t.snapLocked(t.phaseLocked()) + if n == t.name { + e.Menu = &MenuView{ShowBalance: true, Commands: t.hintsLocked()} + } + return []Event{e} +} +func (t *pachinkoTable) status(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + return t.statusLocked(n) +} +func (t *pachinkoTable) join(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if t.name != "" { + return []Event{t.private(n, "Pachinko is occupied.")} + } + t.clearLocked() + t.name = n + e := t.private(n, "") + e.Type = EventJoined + e.Player = n + return append([]Event{e}, t.statusLocked(n)...) +} +func (t *pachinkoTable) clearLocked() { + t.name = "" + t.phase = pachinkoIdle + t.betSize = 0 + t.wallet = nil + t.stake = Wager{} + t.pockets = [pachinkoLanes]PachinkoPocket{} + t.doubling = 0 + t.row = 0 + t.pos = 0 + t.lane = 0 + t.slot = 0 + t.landing = -1 +} +func (t *pachinkoTable) leave(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + if t.phase != pachinkoIdle { + return []Event{t.private(n, "Finish this board before leaving.")} + } + t.clearLocked() + e := t.playerAction(n, n+" leaves Pachinko.", "You leave Pachinko.") + e.Type = EventLeft + e.Player = n + return []Event{e} +} +func (t *pachinkoTable) rebet(n string) (string, int) { + t.mu.Lock() + defer t.mu.Unlock() + if n == t.name && t.phase == pachinkoIdle { + return "", t.betSize + } + return "", 0 +} + +// dealLocked generates a fresh board. It chooses the eight filler pockets and +// the guaranteed premium first, optionally turns one filler into DOUBLE, +// then shuffles all eight prizes into the slots, so the premium and the DOUBLE +// can appear anywhere on the board. +func (t *pachinkoTable) dealLocked() { + var board [pachinkoLanes]PachinkoPocket + for i := 0; i < pachinkoLanes-1; i++ { + ticket := t.rng.Intn(100) + switch { + case ticket < 30: + board[i] = PachinkoPocket{Value: 0.1} + case ticket < 68: + board[i] = PachinkoPocket{Value: 0.5} + case ticket < 90: + board[i] = PachinkoPocket{Value: 1.0} + default: + board[i] = PachinkoPocket{Value: 1.5} + } + } + ticket := t.rng.Intn(pachinkoPremiumRoll) + switch { + case ticket < 2818: + board[pachinkoLanes-1] = PachinkoPocket{Value: 1.7} + case ticket < 9504: + board[pachinkoLanes-1] = PachinkoPocket{Value: 2} + case ticket < 9877: + board[pachinkoLanes-1] = PachinkoPocket{Value: 5} + case ticket < 9989: + board[pachinkoLanes-1] = PachinkoPocket{Value: 10} + default: + board[pachinkoLanes-1] = PachinkoPocket{Value: 100} + } + if t.rng.Intn(pachinkoDoubleRoll) < pachinkoDoubleBelow { + // DOUBLE replaces one of the eight filler prizes, never the premium. + board[t.rng.Intn(pachinkoLanes-1)] = PachinkoPocket{Double: true} + } + t.rng.Shuffle(pachinkoLanes, func(i, j int) { board[i], board[j] = board[j], board[i] }) + t.pockets = board +} + +// startDropLocked validates a chosen top slot and begins the animation. +func (t *pachinkoTable) startDropLocked(slot int) []Event { + if slot < 1 || slot > pachinkoLanes { + return []Event{t.private(t.name, fmt.Sprintf("Choose a top slot from 1 to %d.", pachinkoLanes))} + } + t.slot = slot + t.row = 0 + t.pos = 2 * (slot - 1) + t.lane = slot - 1 + t.landing = -1 + t.phase = pachinkoDropping + e := t.private(t.name, fmt.Sprintf("Dropping from slot %d...", slot)) + e.Snapshot = t.snapLocked(pachinkoDropping) + return []Event{e} +} + +func (t *pachinkoTable) bet(n, spot string, amount int, w Wallet) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + if t.phase != pachinkoIdle { + return []Event{t.private(n, "Finish this board before betting again.")} + } + if spot != "" && spot != "main" { + return []Event{t.private(n, "Use bet N; the board is revealed before you choose a slot.")} + } + if amount < t.Config.MinBet || amount > t.Config.MaxBet { + return []Event{t.private(n, "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) + if err != nil { + return []Event{t.private(n, walletErrorMessage(err, "Not enough chips and credits."))} + } + t.wallet = w + t.stake = stake + t.betSize = amount + t.doubling = 0 + t.row = 0 + t.pos = 0 + t.lane = 0 + t.landing = -1 + t.slot = 0 + t.dealLocked() + t.phase = pachinkoChoosing + board := t.private(n, fmt.Sprintf("Stake: %d credits. Choose a top slot 1-%d.", amount, pachinkoLanes)) + board.Snapshot = t.snapLocked(pachinkoChoosing) + board.Color = "casino_menu" + board.Menu = &MenuView{ShowBalance: true, Commands: t.hintsLocked()} + return []Event{board} +} + +func (t *pachinkoTable) action(n, action string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + f := strings.Fields(strings.ToLower(action)) + if len(f) == 1 && f[0] == "paytable" { + return []Event{t.private(n, "Use table to see the blank board and pay table.")} + } + if t.phase == pachinkoDropping { + return []Event{t.private(n, "Wait for the ball to land.")} + } + if t.phase != pachinkoChoosing { + return []Event{t.private(n, "Use bet N to set a board first.")} + } + if len(f) == 2 && f[0] == "drop" { + f = f[1:] + } + if len(f) != 1 { + return []Event{t.private(n, fmt.Sprintf("Choose a top slot with drop N or a number from 1 to %d.", pachinkoLanes))} + } + slot, err := strconv.Atoi(f[0]) + if err != nil { + return []Event{t.private(n, fmt.Sprintf("Choose a top slot from 1 to %d.", pachinkoLanes))} + } + return t.startDropLocked(slot) +} + +func (t *pachinkoTable) tick() []Event { + t.mu.Lock() + defer t.mu.Unlock() + if t.phase != pachinkoDropping { + return nil + } + t.row++ + t.advanceBallLocked() + events := []Event{t.dropSnapshotLocked()} + if t.row < pachinkoRows { + return events + } + return append(events, t.resolveLocked()...) +} + +func (t *pachinkoTable) dropSnapshotLocked() Event { + e := t.private(t.name, "") + e.Snapshot = t.snapLocked(pachinkoDropping) + return e +} + +// advanceBallLocked takes one 50:50 bounce in half-lane units, reflecting off +// either wall. Half-lane positions keep the walk from locking onto one parity, +// so every pocket is reachable from every top slot, and the display always +// slides the ball one half-lane rather than skipping a column. +func (t *pachinkoTable) advanceBallLocked() { + last := 2*pachinkoLanes - 2 + switch { + case t.pos <= 0: + t.pos = 1 + case t.pos >= last: + t.pos = last - 1 + case t.rng.Intn(2) == 0: + t.pos-- + default: + t.pos++ + } + t.lane = t.pos / 2 +} + +// payoutLocked converts a multiplier into whole credits with unbiased +// rounding: the fractional part becomes a chance of one extra credit. That +// keeps a 0.1x return on a one-credit bet worth exactly its expected value. +func (t *pachinkoTable) payoutLocked(multiplier float64) int { + total := float64(t.betSize) * multiplier + if total >= float64(MaxCredits) { + return MaxCredits + } + paid := int(math.Floor(total)) + if t.rng.Float64() < total-float64(paid) { + paid++ + } + if paid > MaxCredits { + return MaxCredits + } + return paid +} + +// resolveLocked settles the landed pocket. A DOUBLE doubles the board and asks +// for another free drop; any other pocket pays and ends the round. +func (t *pachinkoTable) resolveLocked() []Event { + t.landing = t.pos / 2 + pocket := t.pockets[t.landing] + if pocket.Double { + t.doubling++ + t.phase = pachinkoChoosing + t.slot = 0 + label := "DOUBLE!" + if t.doubling > 1 { + label = fmt.Sprintf("DOUBLE x%d!", t.doubling) + } + announce := t.private(t.name, label+" Every prize doubles. Drop again from any slot, free.") + announce.Color = "casino_big_win" + board := t.private(t.name, "") + board.Color = "casino_menu" + board.Snapshot = t.snapLocked(pachinkoChoosing) + board.Menu = &MenuView{ShowBalance: true, Commands: t.hintsLocked()} + return []Event{announce, board} + } + multiplier := pachinkoMultiplier(pocket.Value, t.doubling) + paid := t.payoutLocked(multiplier) + result := t.private(t.name, "") + result.Snapshot = t.snapLocked(pachinkoResult) + if err := t.wallet.PayCredits(paid, t.stake.ID); err != nil { + t.phase = pachinkoIdle + return []Event{result, t.private(t.name, payoutFailedMessage())} + } + t.stake = Wager{} + t.phase = pachinkoIdle + label := fmt.Sprintf("Slot %d landed [%s]", t.slot, pachinkoPocketText(pocket, t.doubling)) + if t.doubling > 0 { + label = fmt.Sprintf("Slot %d landed [%s after %d DOUBLE]", t.slot, pachinkoPocketText(pocket, t.doubling), t.doubling) + } + result.Settlement = []SettlementLine{{ + Label: label, + Odds: "Varies", + Stake: t.betSize, + Returned: paid, + Result: returnResult(t.betSize, paid), + }} + result.Color = "casino_payout" + if multiplier >= 2 { + result.Color = "casino_big_win" + } + return []Event{result, t.betMenuEvent(t.name, t.hintsLocked())} +} + +func (t *pachinkoTable) markDisconnected(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + if t.phase != pachinkoIdle && t.wallet != nil { + t.wallet.ResolveStake(t.stake.ID) + } + t.clearLocked() + e := t.event(n+" leaves Pachinko.", true) + e.Type = EventLeft + e.Player = n + return []Event{e} +} +func (t *pachinkoTable) refundAll() []Event { + t.mu.Lock() + defer t.mu.Unlock() + var events []Event + if t.phase != pachinkoIdle && t.wallet != nil { + chips, credits, err := t.wallet.RefundOutstanding() + message := fmt.Sprintf("Pachinko closes; %d credits/chips refunded.", chips+credits) + if err != nil { + message = "Pachinko refund could not be persisted; your stake remains pending for recovery." + } + events = append(events, t.private(t.name, message)) + } + t.clearLocked() + return events +} |
