aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/pachinko.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-09 12:32:48 -0400
committerhistoria <[not public]>2026-10-09 12:32:48 -0400
commite95b15ed51832e46c7d0d90f3c84d0ac0b65fbe3 (patch)
tree512ee9e1ce9333670553d90efe55e91d8ecff0fd /internal/casino/pachinko.go
parent9666676766bf21364f4fc4d0d1d336e72f2a91b4 (diff)
downloadthehouseoficarus-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.go785
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
+}