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 }