aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/casino/pachinko.go785
-rw-r--r--internal/casino/pachinko_test.go334
-rw-r--r--internal/casino/registry.go9
-rw-r--r--internal/casino/types.go1
-rw-r--r--internal/game/casino.go18
-rw-r--r--internal/game/casino_ux.go2
-rw-r--r--internal/game/cmd_registry.go16
-rw-r--r--internal/game/game.go4
-rw-r--r--internal/game/look_casino.go4
-rw-r--r--internal/game/look_target.go2
-rw-r--r--internal/game/pachinko_test.go309
-rw-r--r--internal/game/render_pachinko.go404
-rw-r--r--internal/game/roulette_animation_test.go2
13 files changed, 1879 insertions, 11 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
+}
diff --git a/internal/casino/pachinko_test.go b/internal/casino/pachinko_test.go
new file mode 100644
index 0000000..ef20503
--- /dev/null
+++ b/internal/casino/pachinko_test.go
@@ -0,0 +1,334 @@
+package casino
+
+import (
+ "math"
+ "math/rand"
+ "strings"
+ "testing"
+)
+
+func newTestPachinko() *pachinkoTable {
+ return newTable(1, TableConfig{ID: "pachinko", Game: GamePachinko}, rand.New(rand.NewSource(42))).game.(*pachinkoTable)
+}
+
+func TestPachinkoRTP(t *testing.T) {
+ rtp := pachinkoOptimalRTP()
+ t.Logf("optimal RTP %.6f, uniform RTP %.6f", rtp, pachinkoUniformRTP)
+ if math.Abs(rtp-1.0) > 0.01 {
+ t.Fatalf("best-slot RTP %.6f should be 100%%", rtp)
+ }
+ if pachinkoUniformRTP < 0.75 || pachinkoUniformRTP > 0.90 {
+ t.Fatalf("blind RTP %.6f outside the expected band", pachinkoUniformRTP)
+ }
+ if rtp <= pachinkoUniformRTP {
+ t.Fatalf("choosing the best slot must beat a random slot: %.6f <= %.6f", rtp, pachinkoUniformRTP)
+ }
+ e := pachinkoHouseEdge(TableConfig{}, "")
+ if len(e.Parts) != 1 || math.Abs(e.Parts[0].Loss-math.Max(0, 1-rtp)) > 1e-12 {
+ t.Fatalf("house edge does not match the optimal return: %+v", e)
+ }
+ // Two reads must return the same cached value.
+ if again := pachinkoOptimalRTP(); again != rtp {
+ t.Fatalf("cached RTP changed: %.12f -> %.12f", rtp, again)
+ }
+}
+
+func TestPachinkoBoardValue(t *testing.T) {
+ reach := pachinkoReach()
+ for start := range reach {
+ sum := 0.0
+ for _, p := range reach[start] {
+ sum += p
+ }
+ if math.Abs(sum-1) > 1e-9 {
+ t.Fatalf("reach row %d sums to %.12f", start, sum)
+ }
+ }
+ var pockets [pachinkoLanes]PachinkoPocket
+ for i := range pockets {
+ pockets[i] = PachinkoPocket{Value: 1}
+ }
+ if got := pachinkoBoardValue(&reach, &pockets); math.Abs(got-1) > 1e-9 {
+ t.Fatalf("all-1x board worth %.12f, want 1", got)
+ }
+ pockets = [pachinkoLanes]PachinkoPocket{}
+ pockets[0] = PachinkoPocket{Value: 100}
+ want := 0.0
+ for start := 0; start < pachinkoLanes; start++ {
+ if value := 100.0 * reach[start][0]; value > want {
+ want = value
+ }
+ }
+ if got := pachinkoBoardValue(&reach, &pockets); math.Abs(got-want) > 1e-9 {
+ t.Fatalf("lone jackpot worth %.12f, want %.12f", got, want)
+ }
+}
+
+func TestPachinkoReachCoversEveryPocket(t *testing.T) {
+ // The half-lane walk must not lock onto a parity: from every top slot the
+ // ball must be able to reach every pocket, or half the board is dead.
+ reach := pachinkoReach()
+ for start := 0; start < pachinkoLanes; start++ {
+ for pocket := 0; pocket < pachinkoLanes; pocket++ {
+ if reach[start][pocket] <= 0 {
+ t.Fatalf("slot %d can never land in pocket %d", start+1, pocket+1)
+ }
+ }
+ }
+}
+
+func TestPachinkoPremiumLandsInEverySlot(t *testing.T) {
+ // The premium is chosen first and then shuffled, so it must appear in every
+ // slot, not only the last one.
+ p := newTestPachinko()
+ seen := [pachinkoLanes]bool{}
+ for i := 0; i < 5000; i++ {
+ p.dealLocked()
+ for lane, pocket := range p.pockets {
+ if !pocket.Double && pocket.Value > 1.5 {
+ seen[lane] = true
+ }
+ }
+ }
+ for lane, ok := range seen {
+ if !ok {
+ t.Fatalf("premium never landed in slot %d", lane+1)
+ }
+ }
+}
+
+func TestPachinkoBoardComposition(t *testing.T) {
+ // Every dealt board has exactly one guaranteed pocket above 1.5x, at most
+ // one DOUBLE, and never an all-low board without a premium.
+ p := newTestPachinko()
+ seenFive, seenTen, seenHundred := false, false, false
+ for i := 0; i < 20000; i++ {
+ p.dealLocked()
+ premium, doubles := 0, 0
+ for _, pocket := range p.pockets {
+ if pocket.Double {
+ doubles++
+ continue
+ }
+ switch {
+ case pocket.Value >= 100:
+ premium++
+ seenHundred = true
+ case pocket.Value >= 10:
+ premium++
+ seenTen = true
+ case pocket.Value > 1.5:
+ premium++
+ seenFive = true
+ }
+ }
+ if premium != 1 {
+ t.Fatalf("board must carry exactly one >1.5x pocket: %+v", p.pockets)
+ }
+ if doubles > 1 {
+ t.Fatalf("board carried more than one DOUBLE: %+v", p.pockets)
+ }
+ }
+ if !seenFive || !seenTen || !seenHundred {
+ t.Fatalf("premium tiers never all appeared: 5x=%v 10x=%v 100x=%v", seenFive, seenTen, seenHundred)
+ }
+}
+
+func TestPachinkoConfig(t *testing.T) {
+ if (TableConfig{ID: "p", Game: GamePachinko, MaxPlayers: 2}).Validate() == nil {
+ t.Fatal("accepted a two-seat pachinko table")
+ }
+ c := TableConfig{ID: "p", Game: GamePachinko}
+ if err := c.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ if got := c.Normalize(); got.MaxPlayers != 1 {
+ t.Fatalf("normalized max players = %d, want 1", got.MaxPlayers)
+ }
+ if (TableConfig{ID: "p", Game: GamePachinko, Plinko: PlinkoConfig{RTP: 98}}).Validate() == nil {
+ t.Fatal("accepted a plinko rule block on a pachinko table")
+ }
+ // The registry supplies a rule page and an edge analysis.
+ def, ok := tableGameDefFor(GamePachinko)
+ if !ok || def.ruleText == nil || def.houseEdge == nil || def.hasRuleBlock == nil || def.hasRuleBlock(TableConfig{}) {
+ t.Fatalf("pachinko registration incomplete: %+v", def.ruleBlockName)
+ }
+}
+
+func TestPachinkoDropLifecycle(t *testing.T) {
+ p := newTestPachinko()
+ p.join("alice")
+ w := &testWallet{credits: 1000}
+ p.bet("alice", "", 10, w)
+ if p.phase != pachinkoChoosing || p.name != "alice" || len(w.open) != 1 {
+ t.Fatalf("bet did not set up the board: phase %q open %d", p.phase, len(w.open))
+ }
+ // Force a deterministic 2x board so the payout does not depend on the deal.
+ for i := range p.pockets {
+ p.pockets[i] = PachinkoPocket{Value: 2}
+ }
+ events := p.action("alice", "drop 5")
+ if p.phase != pachinkoDropping || p.slot != 5 || p.lane != 4 || p.row != 0 {
+ t.Fatalf("drop did not start from slot 5: %+v", p)
+ }
+ if len(events) == 0 || events[0].Snapshot.(*PachinkoSnapshot).Row != 0 {
+ t.Fatal("drop did not publish the top row")
+ }
+ for row := 1; row <= pachinkoRows; row++ {
+ events = p.tick()
+ if len(events) == 0 {
+ t.Fatalf("row %d produced no event", row)
+ }
+ // The ball falls exactly one row per game tick.
+ snap, ok := events[0].Snapshot.(*PachinkoSnapshot)
+ if !ok || snap.Row != row {
+ t.Fatalf("tick %d advanced to row %d, want %d", row, snap.Row, row)
+ }
+ }
+ if p.phase != pachinkoIdle || len(w.open) != 0 {
+ t.Fatalf("round did not settle: phase %q open %d", p.phase, len(w.open))
+ }
+ if w.paid != 20 {
+ t.Fatalf("2x on a 10-credit bet paid %d, want 20", w.paid)
+ }
+ // No further ticks may pay again.
+ before := w.paid
+ p.tick()
+ if w.paid != before {
+ t.Fatal("duplicate payout after settlement")
+ }
+}
+
+func TestPachinkoDoubleChains(t *testing.T) {
+ p := newTestPachinko()
+ p.join("alice")
+ w := &testWallet{credits: 100}
+ p.bet("alice", "", 10, w)
+ p.pockets = [pachinkoLanes]PachinkoPocket{}
+ p.pockets[4] = PachinkoPocket{Double: true}
+ p.pockets[0] = PachinkoPocket{Value: 10}
+
+ p.phase = pachinkoDropping
+ p.lane, p.pos = 4, 8
+ p.slot = 5
+ p.mu.Lock()
+ events := p.resolveLocked()
+ p.mu.Unlock()
+ if p.phase != pachinkoChoosing || p.doubling != 1 {
+ t.Fatalf("DOUBLE did not double and re-drop: phase %q doubling %d", p.phase, p.doubling)
+ }
+ if len(events) == 0 || !strings.Contains(events[0].Message, "DOUBLE") {
+ t.Fatalf("DOUBLE was not announced: %+v", events)
+ }
+ // The board display doubles the numeric prizes.
+ if got := pachinkoPocketText(p.pockets[0], p.doubling); got != "20x" {
+ t.Fatalf("doubled 10x displays as %q, want 20x", got)
+ }
+ // A second DOUBLE at the same lane doubles again instead of paying.
+ p.phase = pachinkoDropping
+ p.lane, p.pos = 4, 8
+ p.mu.Lock()
+ p.resolveLocked()
+ p.mu.Unlock()
+ if p.phase != pachinkoChoosing || p.doubling != 2 {
+ t.Fatalf("chained DOUBLE failed: phase %q doubling %d", p.phase, p.doubling)
+ }
+ // Landing on the 10x now pays 10 * 2^2 = 40x, i.e. 400 on a 10 stake.
+ p.phase = pachinkoDropping
+ p.lane, p.pos = 0, 0
+ p.mu.Lock()
+ events = p.resolveLocked()
+ p.mu.Unlock()
+ if p.phase != pachinkoIdle || w.paid != 400 || len(w.open) != 0 {
+ t.Fatalf("doubled payout wrong: phase %q paid %d open %d", p.phase, w.paid, len(w.open))
+ }
+ if len(events[0].Settlement) != 1 || events[0].Settlement[0].Returned != 400 {
+ t.Fatalf("doubled settlement missing: %+v", events[0].Settlement)
+ }
+}
+
+func TestPachinkoMultiplierAndRounding(t *testing.T) {
+ if got := pachinkoMultiplier(100, 0); got != 100 {
+ t.Fatalf("plain jackpot = %v, want 100", got)
+ }
+ if got := pachinkoMultiplier(100, 3); got != 800 {
+ t.Fatalf("3-doubled jackpot = %v, want 800", got)
+ }
+ if got := pachinkoMultiplier(100, 40); got != float64(MaxCredits) {
+ t.Fatalf("overflowing multiplier = %v, want the MaxCredits cap", got)
+ }
+ if got := pachinkoPocketText(PachinkoPocket{Value: 0.1}, 1); got != "0.2x" {
+ t.Fatalf("doubled 0.1x displays as %q, want 0.2x", got)
+ }
+ // A 0.1x return on a one-credit bet must average exactly 0.1 credits.
+ p := newTestPachinko()
+ p.betSize = 1
+ total := 0
+ const trials = 200000
+ for i := 0; i < trials; i++ {
+ total += p.payoutLocked(0.1)
+ }
+ if avg := float64(total) / trials; math.Abs(avg-0.1) > 0.01 {
+ t.Fatalf("0.1x rounded to %.4f on average, want 0.1", avg)
+ }
+}
+
+func TestPachinkoRefundAndDisconnect(t *testing.T) {
+ for _, disconnect := range []bool{false, true} {
+ p := newTestPachinko()
+ p.join("alice")
+ w := &testWallet{credits: 100}
+ p.bet("alice", "", 10, w)
+ 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.Fatalf("shutdown failed to refund: credits %d paid %d", w.credits, w.paid)
+ }
+ if disconnect && w.credits != 90 {
+ t.Fatalf("disconnect must forfeit: credits %d", w.credits)
+ }
+ if len(p.tick()) != 0 {
+ t.Fatal("discarded board continued")
+ }
+ }
+}
+
+func TestPachinkoFailedPayoutRetainsStake(t *testing.T) {
+ p := newTestPachinko()
+ p.join("alice")
+ w := &failingPayoutWallet{testWallet: testWallet{credits: 100}}
+ p.bet("alice", "", 10, w)
+ for i := range p.pockets {
+ p.pockets[i] = PachinkoPocket{Value: 2}
+ }
+ p.action("alice", "drop 1")
+ for i := 0; i < pachinkoRows; i++ {
+ p.tick()
+ }
+ if p.phase != pachinkoIdle || len(w.open) != 1 {
+ t.Fatalf("failed payout consumed the stake or stuck the phase: %q open %d", p.phase, len(w.open))
+ }
+ p.leave("alice")
+ if len(w.open) != 1 {
+ t.Fatal("leave consumed the failed stake")
+ }
+}
+
+func TestPachinkoLeaveBlockedMidBoard(t *testing.T) {
+ p := newTestPachinko()
+ p.join("alice")
+ w := &testWallet{credits: 100}
+ p.bet("alice", "", 10, w)
+ if events := p.leave("alice"); len(events) == 0 || !strings.Contains(events[0].Message, "Finish") {
+ t.Fatalf("leaving a committed board must be blocked: %+v", events)
+ }
+ if p.name != "alice" {
+ t.Fatal("blocked leave freed the seat")
+ }
+}
diff --git a/internal/casino/registry.go b/internal/casino/registry.go
index 716782e..988da07 100644
--- a/internal/casino/registry.go
+++ b/internal/casino/registry.go
@@ -63,6 +63,15 @@ func init() {
helpTopic: func(TableConfig) string { return "plinko" },
houseEdge: plinkoHouseEdge,
},
+ GamePachinko: {
+ validate: validatePachinko,
+ normalize: func(c TableConfig) TableConfig { c.MaxPlayers = 1; return c },
+ hasRuleBlock: func(TableConfig) bool { return false },
+ new: newPachinkoTable,
+ ruleText: pachinkoRules,
+ helpTopic: func(TableConfig) string { return "pachinko" },
+ houseEdge: pachinkoHouseEdge,
+ },
GamePaiGow: {
houseEdge: paiGowHouseEdge,
validate: validatePaiGow,
diff --git a/internal/casino/types.go b/internal/casino/types.go
index 9a3f0a1..2b3a0a0 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -18,6 +18,7 @@ const (
GameVideoPoker GameName = "video_poker"
GameSlots GameName = "slots"
GamePlinko GameName = "plinko"
+ GamePachinko GameName = "pachinko"
GameCashHunt GameName = "cash_hunt"
GameBlackjack GameName = "blackjack"
GameBaccarat GameName = "baccarat"
diff --git a/internal/game/casino.go b/internal/game/casino.go
index a0cc675..fa78c2a 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -354,9 +354,11 @@ func (g *Game) executeTableAction(sess *net.Session, action string) {
g.emitCasinoEvents(g.Casino.Action(table.RoomID, table.Config.ID, p.Name, action))
}
-// Keep the automatic post-command prompt out of a seated player's spin
-// animation. Settlement menus resume prompting once the table leaves playing.
-func (g *Game) rouletteSpinning(sess *net.Session) bool {
+// casinoAnimationBusy reports whether a seated player's game is mid-animation:
+// a spinning roulette wheel or Big 6 wheel, or a falling Plinko or Pachinko
+// ball. The automatic prompt stays out of that output and resumes when the
+// table settles.
+func (g *Game) casinoAnimationBusy(sess *net.Session) bool {
if g.Casino == nil || sess == nil || sess.Player == nil {
return false
}
@@ -364,8 +366,8 @@ func (g *Game) rouletteSpinning(sess *net.Session) bool {
if !playing || table == nil {
return false
}
- if table.Config.Game == casino.GameBigSix {
- return table.Busy(sess.Player.Name)
+ if table.Busy(sess.Player.Name) {
+ return true
}
if table.Config.Game != casino.GameRoulette {
return false
@@ -515,12 +517,13 @@ func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *cas
_, isCraps := snapshot.(*casino.CrapsSnapshot)
_, isPoker := snapshot.(*casino.VideoPokerSnapshot)
_, isPlinko := snapshot.(*casino.PlinkoSnapshot)
+ _, isPachinko := snapshot.(*casino.PachinkoSnapshot)
_, isCashHunt := snapshot.(*casino.CashHuntSnapshot)
_, isPaiGow := snapshot.(*casino.PaiGowSnapshot)
b, isBigSix := snapshot.(*casino.BigSixSnapshot)
isBigSix = isBigSix && b.Phase != "playing" && b.Phase != "result"
s, isRoulette := snapshot.(*casino.RouletteSnapshot)
- if !isCashHunt && !isPlinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && (!isRoulette || s.Phase == "playing" || s.Phase == "result") {
+ if !isCashHunt && !isPlinko && !isPachinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && (!isRoulette || s.Phase == "playing" || s.Phase == "result") {
return "", false
}
if isRoulette && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) {
@@ -617,6 +620,9 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot)
return renderCashHunt(s, mode, unicode, rtp)
case *casino.PlinkoSnapshot:
return renderPlinko(s, mode, unicode)
+ case *casino.PachinkoSnapshot:
+ precision, showOdds := g.casinoEdgePrecision(sess)
+ return renderPachinko(s, mode, unicode, showOdds, precision)
case *casino.PaiGowSnapshot:
return renderPaiGow(s, mode, unicode, size, sess.Player.OptionString("card_style"), g.CardArt)
case *casino.VideoPokerSnapshot:
diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go
index 88562cd..7eb10dd 100644
--- a/internal/game/casino_ux.go
+++ b/internal/game/casino_ux.go
@@ -68,6 +68,8 @@ func casinoTableAliases(cfg casino.TableConfig) []string {
switch cfg.Game {
case casino.GameCashHunt:
names = append(names, "cash hunt", "cashhunt", "cash-hunt")
+ case casino.GamePachinko:
+ names = append(names, "pachinko")
case casino.GameBigSix:
names = append(names, "big-six", "big_six", "big six", "big 6")
case casino.GameBlackjack:
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index 2defb9e..ef17925 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -252,6 +252,8 @@ func (g *Game) casinoOwnedInput(sess *net.Session, cmd string) bool {
switch table.Config.Game {
case casino.GameCashHunt:
return isAllDigits(cmd) || cmd == "settle"
+ case casino.GamePachinko:
+ return isAllDigits(cmd)
case casino.GamePaiGow:
return isAllDigits(cmd) || cmd == "set" || cmd == "house" || cmd == "autoset" || cmd == "settle"
}
@@ -291,6 +293,7 @@ var casinoCommonVerbs = []string{"watch", "game", "table", "list", "stop", "rule
var casinoVerbsByGame = map[casino.GameName][]string{
casino.GameCashHunt: {"bet", "rebet", "difficulty", "paytable", "pick", "settle"},
casino.GamePlinko: {"bet", "rebet", "difficulty", "balls", "paytable"},
+ casino.GamePachinko: {"bet", "rebet", "table", "drop"},
casino.GameBlackjack: {"bet", "rebet", "unbet", "hit", "stand", "double", "split", "insurance", "surrender", "decline"},
casino.GameBaccarat: {"bet", "rebet", "unbet"},
casino.GameRoulette: {"bet", "rebet", "unbet"},
@@ -417,6 +420,19 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI
}
}
+ // Pachinko owns bare slot numbers and "drop N" while seated; a "drop"
+ // with any other argument still drops an item from the world.
+ if p != nil && g.Casino != nil {
+ if t, playing := g.Casino.Participation(p.Name); playing && t != nil && t.Config.Game == casino.GamePachinko {
+ numeric := cmd != "" && cmd[0] >= '0' && cmd[0] <= '9'
+ dropSlot := cmd == "drop" && len(args) == 1 && isAllDigits(args[0])
+ if numeric || dropSlot {
+ g.executeTableAction(sess, strings.Join(append([]string{cmd}, args...), " "))
+ return
+ }
+ }
+ }
+
// Pai Gow owns these inputs only while seated; leave normal commands alone.
if p != nil && g.Casino != nil {
if t, playing := g.Casino.Participation(p.Name); playing && t != nil && t.Config.Game == casino.GamePaiGow {
diff --git a/internal/game/game.go b/internal/game/game.go
index 448dcce..6780250 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -586,13 +586,13 @@ func playerDataFileName(relPath string) (name, kind string, ok bool) {
}
func (g *Game) writePrompt(sess *net.Session) {
- if !g.rouletteSpinning(sess) {
+ if !g.casinoAnimationBusy(sess) {
sess.WritePrompt(g.promptStr(sess))
}
}
func (g *Game) reprompt(sess *net.Session) {
- if !g.rouletteSpinning(sess) {
+ if !g.casinoAnimationBusy(sess) {
sess.Reprompt(g.promptStr(sess))
}
}
diff --git a/internal/game/look_casino.go b/internal/game/look_casino.go
index eb0b5f9..e349386 100644
--- a/internal/game/look_casino.go
+++ b/internal/game/look_casino.go
@@ -83,7 +83,7 @@ func (g *Game) renderCasinoTableDetails(sess *net.Session, cfg casino.TableConfi
fmt.Sprintf(" Seated %s", seated),
}
switch cfg.Game {
- case casino.GameRoulette, casino.GameBigSix, casino.GamePlinko, casino.GameCashHunt:
+ case casino.GameRoulette, casino.GameBigSix, casino.GamePlinko, casino.GamePachinko, casino.GameCashHunt:
// Look, like sitting down and the table command, shows the live
// wheel: tracker, racetrack, and betting layout.
if rendered := strings.TrimRight(g.renderCasinoSnapshot(sess, table.Board(sess.Player.Name)), "\n"); rendered != "" {
@@ -107,6 +107,8 @@ func casinoTableSummary(cfg casino.TableConfig) string {
return "100 squares; three difficulties; 98% RTP; prizes up to 50000x"
case casino.GamePlinko:
return fmt.Sprintf("16 rows; four risks; 1-10 balls; near %.4g%% target RTP", cfg.Plinko.Normalize().RTP)
+ case casino.GamePachinko:
+ return "8 slots; 20 reflected rows; populated fractional board"
case casino.GameBlackjack:
b := cfg.Blackjack.Normalize()
dealer := "dealer stands on soft 17"
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index 0ddd8f4..97d7ecb 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -50,7 +50,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
// Felt tables resolve through one data-driven loop; the video-poker cabinet
// has its own cabinet-id/pay-table handling.
- for _, game := range []casino.GameName{casino.GameCashHunt, casino.GamePlinko, casino.GameBigSix, casino.GameBlackjack, casino.GameBaccarat, casino.GameRoulette, casino.GameCraps, casino.GamePaiGow} {
+ for _, game := range []casino.GameName{casino.GameCashHunt, casino.GamePlinko, casino.GamePachinko, casino.GameBigSix, casino.GameBlackjack, casino.GameBaccarat, casino.GameRoulette, casino.GameCraps, casino.GamePaiGow} {
if g.lookCasinoTable(sess, lower, game) {
return
}
diff --git a/internal/game/pachinko_test.go b/internal/game/pachinko_test.go
new file mode 100644
index 0000000..fbf3b35
--- /dev/null
+++ b/internal/game/pachinko_test.go
@@ -0,0 +1,309 @@
+package game
+
+import (
+ "strings"
+ "testing"
+ "unicode/utf8"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+)
+
+func pachinkoTestSnapshot(phase string) *casino.PachinkoSnapshot {
+ s := &casino.PachinkoSnapshot{
+ Phase: phase,
+ RTP: 100.00,
+ Uniform: 83.67,
+ Row: pachinkoRows,
+ Lane: 4,
+ Landing: -1,
+ Doubling: 0,
+ }
+ values := []casino.PachinkoPocket{
+ {Value: 0}, {Value: 2}, {Value: 10}, {Value: 2}, {Value: 100},
+ {Double: true}, {Value: 2}, {Value: 10},
+ }
+ copy(s.Pockets[:], values)
+ if phase == "result" {
+ s.Landing = 4
+ }
+ return s
+}
+
+func TestPachinkoRenderBoard(t *testing.T) {
+ for _, mode := range []string{"none", "ansi", "xterm256"} {
+ for _, unicode := range []bool{false, true} {
+ s := pachinkoTestSnapshot("choosing")
+ out := renderPachinko(s, mode, unicode, false, 0)
+ lines := strings.Split(out, "\n")
+ if len(lines) != pachinkoRows+3 {
+ t.Fatalf("%d lines, want %d", len(lines), pachinkoRows+3)
+ }
+ for _, line := range lines {
+ if got := utf8.RuneCountInString(stripANSI(line)); got != pachinkoWidth {
+ t.Fatalf("line width %d, want %d (%q)", got, pachinkoWidth, stripANSI(line))
+ }
+ }
+ if mode == "none" {
+ if strings.Contains(out, "\x1b") {
+ t.Fatal("plain board has ANSI escapes")
+ }
+ if !strings.Contains(stripANSI(out), "DBL") || !strings.Contains(stripANSI(out), "100x") {
+ t.Fatalf("plain board missing prizes: %q", stripANSI(out))
+ }
+ } else {
+ for bg := 0; bg < 256; bg++ {
+ if strings.Contains(out, color.BgCode(mode, bg)) {
+ t.Fatalf("%s board must not paint a background", mode)
+ }
+ }
+ }
+ }
+ }
+}
+
+func TestPachinkoHeaderSitsBetweenPegs(t *testing.T) {
+ s := pachinkoTestSnapshot("choosing")
+ lines := strings.Split(stripANSI(renderPachinko(s, "none", false, false, 0)), "\n")
+ header, firstPegs := []rune(lines[0]), []rune(lines[1])
+ for lane := 0; lane < pachinkoLanes; lane++ {
+ x := pachinkoLaneX(lane)
+ if header[x] != rune('1'+lane) {
+ t.Fatalf("header %d not at x=%d: %q", lane+1, x, string(header))
+ }
+ // The number must sit in a gap of the first peg row, not on a pin or
+ // the wall.
+ if firstPegs[x] != ' ' {
+ t.Fatalf("slot %d header overlaps a pin or wall: %q", lane+1, string(firstPegs))
+ }
+ near := false
+ for _, dx := range []int{-pachinkoPitch / 2, pachinkoPitch / 2} {
+ if at := x + dx; at >= 0 && at < len(firstPegs) && firstPegs[at] == '.' {
+ near = true
+ }
+ }
+ if !near {
+ t.Fatalf("slot %d header not between pins: %q", lane+1, string(firstPegs))
+ }
+ }
+}
+
+func TestPachinkoRenderDroppingIsOneLine(t *testing.T) {
+ s := pachinkoTestSnapshot("dropping")
+ for row := 0; row <= pachinkoRows; row++ {
+ for lane := 0; lane < pachinkoLanes; lane++ {
+ s.Row, s.Lane = row, lane
+ out := renderPachinko(s, "none", true, false, 0)
+ if strings.Contains(out, "\n") {
+ t.Fatalf("row %d lane %d printed more than one line", row, lane)
+ }
+ if got := utf8.RuneCountInString(stripANSI(out)); got != pachinkoWidth {
+ t.Fatalf("row %d lane %d width %d, want %d", row, lane, got, pachinkoWidth)
+ }
+ // The ball sits exactly on its staggered lane column.
+ plain := []rune(stripANSI(out))
+ if plain[pachinkoDropX(row, lane)] != 'โ—' {
+ t.Fatalf("row %d lane %d ball misplaced: %q", row, lane, string(plain))
+ }
+ }
+ }
+}
+
+func TestPachinkoRenderResultMarksLanding(t *testing.T) {
+ s := pachinkoTestSnapshot("result")
+ s.Landing = 4
+ out := stripANSI(renderPachinko(s, "none", true, false, 0))
+ lines := strings.Split(out, "\n")
+ // The settled view is only the prize row: the drop already drew the ball
+ // path, so the result must not reprint the board or its number header.
+ if len(lines) != 1 {
+ t.Fatalf("result should be one prize row, got %d lines: %q", len(lines), out)
+ }
+ if got := utf8.RuneCountInString(lines[0]); got != pachinkoWidth {
+ t.Fatalf("result width %d, want %d", got, pachinkoWidth)
+ }
+ if !strings.Contains(lines[len(lines)-1], "[100x]") {
+ t.Fatalf("landed jackpot not bracketed: %q", lines[len(lines)-1])
+ }
+ if strings.Count(lines[len(lines)-1], "[") != 1 {
+ t.Fatalf("only the landed pocket may be bracketed: %q", lines[len(lines)-1])
+ }
+}
+
+func TestPachinkoRenderDoubledLabels(t *testing.T) {
+ s := pachinkoTestSnapshot("choosing")
+ s.Doubling = 2
+ out := stripANSI(renderPachinko(s, "none", true, false, 0))
+ // 0, 2, 10, 2, 100, DBL, 2, 10, 0 doubled twice: 0, 8, 40, 8, 400, DBL, 8, 40, 0.
+ for _, want := range []string{"8x", "40x", "400x", "DBL"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("doubled board missing %q: %q", want, out)
+ }
+ }
+}
+
+func TestPachinkoIdleShowsIntroOnly(t *testing.T) {
+ s := &casino.PachinkoSnapshot{Phase: "idle", RTP: 100.00, Uniform: 83.67, Odds: casino.PachinkoOdds()}
+ out := stripANSI(renderPachinko(s, "none", true, false, 0))
+ if !strings.Contains(out, "You stand atop a giant peg board") {
+ t.Fatalf("idle view missing the intro: %q", out)
+ }
+ // Sitting down must not dump the blank board or pay table.
+ if strings.Contains(out, "Pachinko pay table") || strings.Contains(out, "?") || strings.Contains(out, "Prize") {
+ t.Fatalf("idle view leaked the reference table: %q", out)
+ }
+}
+
+func TestPachinkoPaytableRender(t *testing.T) {
+ s := &casino.PachinkoSnapshot{Phase: "paytable", RTP: 100.00, Uniform: 83.67}
+ s.Odds = casino.PachinkoOdds()
+ out := stripANSI(renderPachinko(s, "none", true, true, 2))
+ for _, want := range []string{"100.00%", "83.67%", "0.1-1.5x", "1.7x", "2x", "5x", "10x", "100x", "DOUBLE"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("paytable missing %q: %q", want, out)
+ }
+ }
+ if !strings.Contains(out, "Prize") || !strings.Contains(out, "Chance") || !strings.Contains(out, "Odds") {
+ t.Fatalf("paytable missing its columns: %q", out)
+ }
+}
+
+func TestPachinkoPaytableHidesOdds(t *testing.T) {
+ s := &casino.PachinkoSnapshot{Phase: "paytable", RTP: 100.00, Uniform: 83.67, Odds: casino.PachinkoOdds()}
+ out := stripANSI(renderPachinko(s, "none", true, false, 2))
+ if !strings.Contains(out, "Pachinko pay table") || !strings.Contains(out, "DOUBLE") {
+ t.Fatalf("reference view missing the table: %q", out)
+ }
+ // Without a House Edge instrument the exact percentages stay hidden.
+ for _, leak := range []string{"100.00%", "83.67%", "28.18%", "5.00%"} {
+ if strings.Contains(out, leak) {
+ t.Fatalf("paytable leaked odds %q without an instrument: %q", leak, out)
+ }
+ }
+}
+
+func TestPachinkoPaytablePrecision(t *testing.T) {
+ s := &casino.PachinkoSnapshot{Phase: "paytable", RTP: 100.00, Uniform: 80.09, Odds: casino.PachinkoOdds()}
+ out := stripANSI(renderPachinko(s, "none", true, true, 0))
+ if !strings.Contains(out, "4%") || strings.Contains(out, "3.73%") {
+ t.Fatalf("whole-percent instrument should round: %q", out)
+ }
+ out = stripANSI(renderPachinko(s, "none", true, true, 2))
+ if !strings.Contains(out, "3.73%") {
+ t.Fatalf("hundredth-percent instrument should not round: %q", out)
+ }
+}
+
+func TestPachinkoCommandsAndReceipt(t *testing.T) {
+ g, s, conn := casinoCrapsGame(t, "name: Pins\ncasino_tables:\n - id: pachinko\n game: pachinko\n")
+ s.Player.Options = map[string]any{"color": "none", "casino_hints": "off"}
+ g.executePlay(s, []string{"pachinko"}, "play pachinko")
+ table, ok := g.Casino.Participation(s.Player.Name)
+ if !ok || table == nil || table.Config.Game != casino.GamePachinko {
+ t.Fatal("pachinko was not seated")
+ }
+ conn.data = nil
+ g.executeBet(s, []string{"1"}, "bet 1")
+ if text := stripANSI(string(conn.data)); !strings.Contains(text, "Choose a top slot") || !strings.Contains(text, "0.1x") {
+ t.Fatalf("bet did not reveal a board: %s", text)
+ }
+ if table.Busy(s.Player.Name) {
+ t.Fatal("choosing a slot must not suppress the input prompt")
+ }
+ // A bare slot number must reach the game rather than being rejected.
+ conn.data = nil
+ g.handleGameCommand(s, "4")
+ if strings.Contains(string(conn.data), "Unknown command") {
+ t.Fatalf("bare slot rejected: %s", conn.data)
+ }
+ if !table.Busy(s.Player.Name) {
+ t.Fatal("drop did not start")
+ }
+ // Settle, re-dropping through any chained DOUBLEs.
+ for i := 0; i < 2000; i++ {
+ switch table.State() {
+ case "waiting":
+ i = 5000
+ case "choosing a slot":
+ g.executeTableAction(s, "4")
+ case "dropping":
+ g.emitCasinoEvents(g.Casino.Tick())
+ }
+ }
+ if table.State() != "waiting" || len(s.Player.PendingWagers) != 0 {
+ t.Fatalf("round did not settle: state %q pending %d", table.State(), len(s.Player.PendingWagers))
+ }
+ if text := stripANSI(string(conn.data)); !strings.Contains(text, "Returned:") || !strings.Contains(text, "Staked") {
+ t.Fatalf("settlement receipt missing: %s", text)
+ }
+ conn.data = nil
+ g.doLookTarget(s, "pachinko")
+ if text := stripANSI(string(conn.data)); !strings.Contains(text, "giant peg board") {
+ t.Fatalf("look missing the reference board: %s", text)
+ }
+ conn.data = nil
+ g.executeRules(s, nil, "rules")
+ if text := stripANSI(string(conn.data)); !strings.Contains(text, "PACHINKO") {
+ t.Fatalf("rules did not open the pachinko help page: %s", text)
+ }
+}
+
+func TestPachinkoRejectsPickingSlotBeforeBoard(t *testing.T) {
+ g, s, conn := casinoCrapsGame(t, "name: Pins\ncasino_tables:\n - id: pachinko\n game: pachinko\n")
+ s.Player.Options = map[string]any{"color": "none", "casino_hints": "off"}
+ g.executePlay(s, []string{"pachinko"}, "play pachinko")
+ conn.data = nil
+ g.executeBet(s, []string{"1", "on", "5"}, "bet 1 on 5")
+ table, _ := g.Casino.Participation(s.Player.Name)
+ if table.State() != "waiting" {
+ t.Fatalf("bet on SLOT should be rejected before the board exists: %q", table.State())
+ }
+ if len(s.Player.PendingWagers) != 0 {
+ t.Fatal("rejected bet must not commit a stake")
+ }
+ if !strings.Contains(stripANSI(string(conn.data)), "board is revealed before you choose") {
+ t.Fatalf("missing rejection: %s", conn.data)
+ }
+}
+
+func TestPachinkoDropSuppressesPrompt(t *testing.T) {
+ g, s, conn := casinoCrapsGame(t, "name: Pins\ncasino_tables:\n - id: pachinko\n game: pachinko\n")
+ s.Player.Options = map[string]any{"color": "none", "casino_hints": "off"}
+ g.executePlay(s, []string{"pachinko"}, "play pachinko")
+ g.executeBet(s, []string{"1"}, "bet 1")
+ conn.data = nil
+ g.handleGameCommand(s, "4")
+ if !g.casinoAnimationBusy(s) {
+ t.Fatal("drop did not mark the table busy")
+ }
+ before := len(conn.data)
+ g.writePrompt(s) // the command queue's post-command prompt
+ if len(conn.data) != before {
+ t.Fatalf("prompt interrupted the drop: %q", conn.data)
+ }
+}
+
+func TestPachinkoDropVerbKeepsItemDrop(t *testing.T) {
+ // "drop sword" must stay a world command, not a casino slot.
+ g, s, conn := casinoCrapsGame(t, "name: Pins\ncasino_tables:\n - id: pachinko\n game: pachinko\n")
+ s.Player.Options = map[string]any{"color": "none", "casino_hints": "off"}
+ g.executePlay(s, []string{"pachinko"}, "play pachinko")
+ g.executeBet(s, []string{"1"}, "bet 1")
+ conn.data = nil
+ g.executeCommand(s, "drop", []string{"sword"}, "drop sword")
+ if strings.Contains(string(conn.data), "Choose a top slot") {
+ t.Fatalf("drop with a non-slot argument leaked into the casino: %s", conn.data)
+ }
+}
+
+func TestPachinkoHelpAndAliases(t *testing.T) {
+ for _, name := range []string{"pachinko", "pachinko machine"} {
+ g, s, _ := casinoCrapsGame(t, "name: Pins\ncasino_tables:\n - id: pachinko\n game: pachinko\n")
+ args := strings.Fields(name)
+ g.executePlay(s, args, "play "+name)
+ table, ok := g.Casino.Participation(s.Player.Name)
+ if !ok || table == nil || table.Config.Game != casino.GamePachinko {
+ t.Fatalf("alias %q did not seat the player", name)
+ }
+ }
+}
diff --git a/internal/game/render_pachinko.go b/internal/game/render_pachinko.go
new file mode 100644
index 0000000..e973f04
--- /dev/null
+++ b/internal/game/render_pachinko.go
@@ -0,0 +1,404 @@
+package game
+
+import (
+ "fmt"
+ "math"
+ "strconv"
+ "strings"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/ui"
+)
+
+// The rectangle is drawn on a fixed pitch: eight lanes at x = 3+6k, pegs on
+// the boundaries at x = 6+6k, and walls at both ends. Every scanline has the
+// same width, so a drop prints one row per tick and the ball's path reads as a
+// diagonal column of frames. The right wall sits one half-lane past the last
+// lane, mirroring the left wall, so the outer slots have the same clearance on
+// both sides.
+const (
+ pachinkoLanes = 8
+ pachinkoRows = 20
+ pachinkoPitch = 6
+ pachinkoWidth = pachinkoLanes*pachinkoPitch + 1
+ pachinkoBallX = 3
+ pachinkoPegGap = 6
+)
+
+// pachinkoLaneX is the fixed column of a lane on an even scanline; the prize
+// row and the top labels use it so a settled ball lines up with its pocket.
+func pachinkoLaneX(lane int) int { return pachinkoBallX + pachinkoPitch*lane }
+
+// pachinkoDropX staggers the ball's column on odd rows so a ball that holds its
+// lane visibly weaves between the pins instead of sliding down a static grid.
+func pachinkoDropX(row, lane int) int {
+ if row%2 == 0 {
+ return pachinkoLaneX(lane)
+ }
+ return pachinkoPitch * (lane + 1)
+}
+
+// pachinkoGradientRGB gives the board a warm jewel ramp: deep violet walls,
+// through blue and teal, to a golden-red right edge.
+func pachinkoGradientRGB(x int) [3]int {
+ stops := [5][3]int{{150, 80, 255}, {60, 170, 255}, {40, 230, 190}, {255, 210, 60}, {255, 90, 70}}
+ distance := float64(x) / float64(pachinkoWidth-1)
+ segment := min(3, int(distance*4))
+ blend := distance*4 - float64(segment)
+ var rgb [3]int
+ for i := range rgb {
+ rgb[i] = int(math.Round(float64(stops[segment][i])*(1-blend) + float64(stops[segment+1][i])*blend))
+ }
+ return rgb
+}
+
+func pachinkoColorAt(x int, scale float64) int {
+ rgb := pachinkoGradientRGB(x)
+ return color.NearestXterm256(
+ max(0, min(255, int(float64(rgb[0])*scale))),
+ max(0, min(255, int(float64(rgb[1])*scale))),
+ max(0, min(255, int(float64(rgb[2])*scale))))
+}
+
+func pachinkoPegColor(x int) int { return pachinkoColorAt(x, 0.45) }
+func pachinkoWallColor(x int) int { return pachinkoColorAt(x, 0.85) }
+func pachinkoHeaderColor(x int) int { return pachinkoColorAt(x, 1.0) }
+
+// pachinkoPocketColor maps a prize to a readable color; doubling shifts an
+// already-valuable pocket hotter.
+func pachinkoPocketColor(p casino.PachinkoPocket, doubling int) int {
+ switch {
+ case p.Double:
+ if doubling > 0 {
+ return 201 // bright magenta
+ }
+ return 198 // magenta
+ case p.Value >= 100:
+ return 51 // neon cyan
+ case p.Value >= 10:
+ if doubling > 0 {
+ return 226
+ }
+ return 220 // gold
+ case p.Value >= 5:
+ return 208 // orange
+ case p.Value >= 2:
+ return 46 // green
+ case p.Value >= 1:
+ return 49 // teal: break-even and small fractions
+ case p.Value > 0:
+ return 245 // dim fraction
+ default:
+ return 240 // dim slate for 0x
+ }
+}
+
+func pachinkoPocketBold(p casino.PachinkoPocket, doubling int) bool {
+ return !p.Double && (p.Value >= 2 || doubling > 0)
+}
+
+type pachinkoCell struct {
+ glyph rune
+ fg int
+ bold bool
+}
+
+type pachinkoLine [pachinkoWidth]pachinkoCell
+
+func newPachinkoLine() pachinkoLine {
+ var line pachinkoLine
+ for x := range line {
+ line[x] = pachinkoCell{glyph: ' ', fg: -1}
+ }
+ return line
+}
+
+func (line *pachinkoLine) put(x int, glyph rune, fg int) {
+ if x >= 0 && x < len(line) {
+ line[x] = pachinkoCell{glyph: glyph, fg: fg}
+ }
+}
+
+func (line *pachinkoLine) putBold(x int, glyph rune, fg int) {
+ if x >= 0 && x < len(line) {
+ line[x] = pachinkoCell{glyph: glyph, fg: fg, bold: true}
+ }
+}
+
+// centeredStart returns the first cell of a text of length n centered in the
+// 6-wide lane k cell.
+func pachinkoCenteredStart(lane, n int) int {
+ if n > pachinkoPitch {
+ n = pachinkoPitch
+ }
+ return pachinkoPitch*lane + (pachinkoPitch-n+1)/2
+}
+
+func (line pachinkoLine) render(mode string) string {
+ var b strings.Builder
+ for x := 0; x < len(line); {
+ cell := line[x]
+ end := x + 1
+ for end < len(line) && line[end].fg == cell.fg && line[end].bold == cell.bold {
+ end++
+ }
+ var text strings.Builder
+ for _, c := range line[x:end] {
+ text.WriteRune(c.glyph)
+ }
+ b.WriteString(color.Render(mode, color.ColorSpec{Fg: cell.fg, Bg: -1, Bold: cell.bold}, text.String()))
+ x = end
+ }
+ return b.String()
+}
+
+func pachinkoGlyphs(unicode bool) (ball, peg rune) {
+ if unicode {
+ return 'โ—', 'ยท'
+ }
+ return 'o', '.'
+}
+
+// pachinkoScanline draws one staggered peg row, optionally with the ball in a
+// lane.
+func pachinkoScanline(row, ballLane int, mode string, unicode bool) string {
+ ball, peg := pachinkoGlyphs(unicode)
+ line := newPachinkoLine()
+ line.put(0, '|', pachinkoWallColor(0))
+ line.put(pachinkoWidth-1, '|', pachinkoWallColor(pachinkoWidth-1))
+ if row%2 == 0 {
+ for k := 0; k < pachinkoLanes-1; k++ {
+ x := pachinkoPegGap + pachinkoPitch*k
+ line.put(x, peg, pachinkoPegColor(x))
+ }
+ } else {
+ for k := 0; k < pachinkoLanes; k++ {
+ x := pachinkoBallX + pachinkoPitch*k
+ line.put(x, peg, pachinkoPegColor(x))
+ }
+ }
+ if ballLane >= 0 && ballLane < pachinkoLanes {
+ line.putBold(pachinkoDropX(row, ballLane), ball, 231)
+ }
+ return line.render(mode)
+}
+
+// pachinkoPocketLabel is the text shown for a landed value after doublings.
+func pachinkoPocketLabel(p casino.PachinkoPocket, doubling int) string {
+ if p.Double {
+ return "DBL"
+ }
+ value := p.Value
+ for i := 0; i < doubling && i < 62; i++ {
+ value *= 2
+ if value > casino.MaxCredits {
+ return "MAX"
+ }
+ }
+ if value > casino.MaxCredits {
+ return "MAX"
+ }
+ return strconv.FormatFloat(value, 'f', -1, 64) + "x"
+}
+
+// pachinkoPocketRow prints the prize row, marking the landed pocket.
+func pachinkoPocketRow(s *casino.PachinkoSnapshot, mode string) string {
+ var b strings.Builder
+ for lane := 0; lane < pachinkoLanes; lane++ {
+ pocket := s.Pockets[lane]
+ label := pachinkoPocketLabel(pocket, s.Doubling)
+ landed := s.Landing == lane && (s.Phase == "result" || s.Phase == "dropping")
+ text := label
+ if landed && len([]rune("["+label+"]")) <= pachinkoPitch {
+ text = "[" + label + "]"
+ }
+ fg := pachinkoPocketColor(pocket, s.Doubling)
+ bold := pachinkoPocketBold(pocket, s.Doubling)
+ if landed {
+ bold = true
+ switch {
+ case pocket.Double:
+ fg = 201
+ case pocket.Value >= 100:
+ fg = 51
+ default:
+ fg = 226
+ }
+ }
+ runes := []rune(text)
+ if len(runes) > pachinkoPitch {
+ runes = runes[:pachinkoPitch]
+ }
+ lead := pachinkoCenteredStart(lane, len(runes)) - pachinkoPitch*lane
+ if lead < 0 {
+ lead = 0
+ }
+ trail := pachinkoPitch - lead - len(runes)
+ if trail < 0 {
+ trail = 0
+ }
+ b.WriteString(strings.Repeat(" ", lead))
+ b.WriteString(color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Bold: bold}, string(runes)))
+ b.WriteString(strings.Repeat(" ", trail))
+ }
+ // The cells span pachinkoLanes*pachinkoPitch; pad to the board width.
+ b.WriteString(strings.Repeat(" ", pachinkoWidth-pachinkoLanes*pachinkoPitch))
+ return b.String()
+}
+
+// renderPachinkoBoard draws the whole rectangle: the numbered top slots, the
+// peg rows, and the prize row. The labels sit in the gaps of the first peg row
+// (not on its pins) so they read as slots to drop the ball into. The first
+// drawn row is the even entry row, whose pins bracket each slot number.
+func renderPachinkoBoard(s *casino.PachinkoSnapshot, mode string, unicode bool) string {
+ lines := make([]string, 0, pachinkoRows+3)
+ header := newPachinkoLine()
+ for lane := 0; lane < pachinkoLanes; lane++ {
+ x := pachinkoLaneX(lane)
+ header.put(x, rune('1'+lane), pachinkoHeaderColor(x))
+ }
+ lines = append(lines, header.render(mode))
+ for row := 0; row <= pachinkoRows; row++ {
+ lines = append(lines, pachinkoScanline(row, -1, mode, unicode))
+ }
+ lines = append(lines, pachinkoPocketRow(s, mode))
+ return strings.Join(lines, "\n")
+}
+
+// pachinkoBlankPocketRow prints a prize row of question marks for the
+// reference view, where no board has been dealt yet.
+func pachinkoBlankPocketRow(mode string) string {
+ var b strings.Builder
+ for lane := 0; lane < pachinkoLanes; lane++ {
+ x := pachinkoLaneX(lane)
+ fg := pachinkoColorAt(x, 0.6)
+ lead := pachinkoCenteredStart(lane, 1) - pachinkoPitch*lane
+ if lead < 0 {
+ lead = 0
+ }
+ trail := pachinkoPitch - lead - 1
+ if trail < 0 {
+ trail = 0
+ }
+ b.WriteString(strings.Repeat(" ", lead))
+ b.WriteString(color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Bold: true}, "?"))
+ b.WriteString(strings.Repeat(" ", trail))
+ }
+ b.WriteString(strings.Repeat(" ", pachinkoWidth-pachinkoLanes*pachinkoPitch))
+ return b.String()
+}
+
+// renderPachinkoBlankBoard draws the numbered top slots, the peg rows, and a
+// row of question marks in place of the pockets.
+func renderPachinkoBlankBoard(mode string, unicode bool) string {
+ lines := make([]string, 0, pachinkoRows+3)
+ header := newPachinkoLine()
+ for lane := 0; lane < pachinkoLanes; lane++ {
+ x := pachinkoLaneX(lane)
+ header.put(x, rune('1'+lane), pachinkoHeaderColor(x))
+ }
+ lines = append(lines, header.render(mode))
+ for row := 0; row <= pachinkoRows; row++ {
+ lines = append(lines, pachinkoScanline(row, -1, mode, unicode))
+ }
+ lines = append(lines, pachinkoBlankPocketRow(mode))
+ return strings.Join(lines, "\n")
+}
+
+// pachinkoOddsColor maps a prize tier to the color its label uses in the table.
+func pachinkoOddsColor(label string) int {
+ switch label {
+ case "1.7x":
+ return 49
+ case "2x":
+ return 46
+ case "5x":
+ return 208
+ case "10x":
+ return 220
+ case "DOUBLE":
+ return 198
+ case "100x":
+ return 51
+ default:
+ return 240
+ }
+}
+
+// renderPachinkoOddsTable lays the board composition out as a bordered table.
+// The chance column carries the exact percentages, so it is blanked unless the
+// player carries a House Edge instrument.
+func renderPachinkoOddsTable(odds []casino.PachinkoPrizeOdds, mode string, unicode, showOdds bool, precision int) string {
+ rows := make([][]string, 0, len(odds))
+ for _, odd := range odds {
+ chance := "?"
+ if showOdds {
+ if odd.Chance > 0 {
+ chance = strconv.FormatFloat(100*odd.Chance, 'f', precision, 64) + "%"
+ } else {
+ chance = "โ€”"
+ }
+ }
+ label := color.Render(mode, color.ColorSpec{Fg: pachinkoOddsColor(odd.Label), Bg: -1, Bold: odd.Label == "DOUBLE" || odd.Label == "100x"}, odd.Label)
+ rows = append(rows, []string{label, chance, odd.Odds})
+ }
+ table := ui.Table{
+ Title: "Pachinko pay table",
+ Columns: []string{"Prize", "Chance", "Odds"},
+ Rows: rows,
+ }
+ return strings.Join(table.Render(unicode, 0), "\n")
+}
+
+// pachinkoIntro is the framing text every player sees, whether they are just
+// sitting down or reading the table reference. It is never gated by items.
+func pachinkoIntro(mode string) string {
+ return color.Render(mode, color.ColorSpec{Fg: 253, Bg: -1}, "You stand atop a giant peg board with 8 slots and 20 rows. Win multipliers are randomized as soon as you place a bet.")
+}
+
+// renderPachinkoReference is the table command's view: the framing text, a
+// blank board, and the pay table. The RTP footer appears only with a House Edge
+// instrument, matching the gated chance column.
+func renderPachinkoReference(s *casino.PachinkoSnapshot, mode string, unicode, showOdds bool, precision int) string {
+ parts := []string{
+ pachinkoIntro(mode),
+ "",
+ renderPachinkoBlankBoard(mode, unicode),
+ "",
+ renderPachinkoOddsTable(s.Odds, mode, unicode, showOdds, precision),
+ }
+ if showOdds {
+ format := func(v float64) string { return strconv.FormatFloat(v, 'f', precision, 64) + "%" }
+ parts = append(parts, "", fmt.Sprintf("Best slot: %s RTP Random slot: %s RTP", format(s.RTP), format(s.Uniform)))
+ }
+ return strings.Join(parts, "\n")
+}
+
+// renderPachinko renders the game view: two scanlines per tick while the ball
+// falls, the full board while choosing, the prize row once it settles, and the
+// blank board plus pay table for the table command. showOdds controls whether
+// the exact percentages appear; precision is the House Edge instrument's
+// rounding (0 or 2 decimal places).
+func renderPachinko(s *casino.PachinkoSnapshot, mode string, unicode, showOdds bool, precision int) string {
+ if s == nil {
+ return ""
+ }
+ switch s.Phase {
+ case "paytable":
+ return renderPachinkoReference(s, mode, unicode, showOdds, precision)
+ case "idle":
+ // Sitting down shows the framing text and the menu, but not the blank
+ // board or pay table; those wait for the table command.
+ return pachinkoIntro(mode)
+ case "dropping":
+ return pachinkoScanline(s.Row, s.Lane, mode, unicode)
+ case "result":
+ // The ball path is already on screen; the settled view is just the
+ // prize row so the multipliers land as the last line, with no repeated
+ // board or number header.
+ return pachinkoPocketRow(s, mode)
+ default:
+ return renderPachinkoBoard(s, mode, unicode)
+ }
+}
diff --git a/internal/game/roulette_animation_test.go b/internal/game/roulette_animation_test.go
index d7f5626..910e781 100644
--- a/internal/game/roulette_animation_test.go
+++ b/internal/game/roulette_animation_test.go
@@ -47,7 +47,7 @@ func TestRouletteSpinOutputHasNoPromptOrBlankLines(t *testing.T) {
}
g.CasinoTick()
result := strings.ReplaceAll(string(conn.data), "\r\n", "\n")
- if !strings.HasPrefix(result, "\nThe ball lands in") || g.rouletteSpinning(sess) {
+ if !strings.HasPrefix(result, "\nThe ball lands in") || g.casinoAnimationBusy(sess) {
t.Fatalf("result did not start on a blank line or end animation: %q", result)
}
// A resolved spin shows the result and the next-spin menu, but not the