diff options
| author | historia <[not public]> | 2026-10-09 12:32:48 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-09 12:32:48 -0400 |
| commit | e95b15ed51832e46c7d0d90f3c84d0ac0b65fbe3 (patch) | |
| tree | 512ee9e1ce9333670553d90efe55e91d8ecff0fd /internal | |
| parent | 9666676766bf21364f4fc4d0d1d336e72f2a91b4 (diff) | |
| download | thehouseoficarus-e95b15ed51832e46c7d0d90f3c84d0ac0b65fbe3.tar.gz | |
feat: pachinko slots randomized, 9th slot removed, RTP set to 100% if played perfectly
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/casino/pachinko.go | 785 | ||||
| -rw-r--r-- | internal/casino/pachinko_test.go | 334 | ||||
| -rw-r--r-- | internal/casino/registry.go | 9 | ||||
| -rw-r--r-- | internal/casino/types.go | 1 | ||||
| -rw-r--r-- | internal/game/casino.go | 18 | ||||
| -rw-r--r-- | internal/game/casino_ux.go | 2 | ||||
| -rw-r--r-- | internal/game/cmd_registry.go | 16 | ||||
| -rw-r--r-- | internal/game/game.go | 4 | ||||
| -rw-r--r-- | internal/game/look_casino.go | 4 | ||||
| -rw-r--r-- | internal/game/look_target.go | 2 | ||||
| -rw-r--r-- | internal/game/pachinko_test.go | 309 | ||||
| -rw-r--r-- | internal/game/render_pachinko.go | 404 | ||||
| -rw-r--r-- | internal/game/roulette_animation_test.go | 2 |
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 |
