diff options
| -rw-r--r-- | building_guide/rooms.md | 21 | ||||
| -rw-r--r-- | data/help/casino.yaml | 3 | ||||
| -rw-r--r-- | data/help/pachinko.yaml | 53 | ||||
| -rw-r--r-- | data/help/plinko.yaml | 2 | ||||
| -rw-r--r-- | data/rooms/casino/6201.yaml | 4 | ||||
| -rw-r--r-- | data/rooms/casino/6202.yaml | 10 | ||||
| -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 |
19 files changed, 1968 insertions, 15 deletions
diff --git a/building_guide/rooms.md b/building_guide/rooms.md index 31e45f0..608dcf5 100644 --- a/building_guide/rooms.md +++ b/building_guide/rooms.md @@ -246,7 +246,7 @@ Multiplayer table fields (`casino_tables`): | Field | Type | Default | Notes | |---|---|---|---| | `id` | string | — | Required. Nonempty, no surrounding whitespace, unique among the room's tables and machines. | -| `game` | string | — | Required. `blackjack`, `baccarat`, `roulette`, `craps`, `paigow`, `video_poker`, or `plinko`. | +| `game` | string | — | Required. `blackjack`, `baccarat`, `roulette`, `craps`, `paigow`, `video_poker`, `plinko`, `pachinko`, or `cash_hunt`. | | `max_players` | int | 8 | 0–64. Video poker is forced to 1; Pai Gow caps at 6. | | `betting_ticks` | int | 20 | 0–3600. | | `min_bet` | int | 1 | Must not exceed `max_bet`. | @@ -292,6 +292,25 @@ casino_tables: `max_players` may only be omitted, `0`, or `1`. Bet limits are per ball, and `max_bet` cannot exceed 10000000. +#### Pachinko + +Pachinko is a single-seat fixture under `casino_tables`, addressed with +`play pachinko`: + +```yaml +casino_tables: + - id: pachinko + game: pachinko + min_bet: 1 + max_bet: 10000 +``` + +No game-specific rule block is needed: the board is randomly drawn each +round and the best-slot return is exactly 100%. `max_players` must be omitted, +`0`, or `1`. There is no `max_bet` multiplier floor because the rare DOUBLE +space can chain an arbitrarily large payout; any return beyond the economy's +credit cap is clamped rather than rejected. + #### Blackjack ```yaml diff --git a/data/help/casino.yaml b/data/help/casino.yaml index fc7d85f..fe81bad 100644 --- a/data/help/casino.yaml +++ b/data/help/casino.yaml @@ -1,7 +1,7 @@ name: casino aliases: [play, bet, rebet, table, watch, rules] category: Interaction -related: [big6, roulette, racetrack, blackjack, baccarat, craps, slots, video poker, pai gow poker, plinko, cash hunt, predictions, crypto] +related: [big6, roulette, racetrack, blackjack, baccarat, craps, slots, video poker, pai gow poker, plinko, pachinko, cash hunt, predictions, crypto] description: | Casino quick guide @@ -53,6 +53,7 @@ description: | help craps Multiplayer dice, free odds, Fire and ATS bets. help slots Matching symbols, ways and free spins. help plinko Animated pyramid, four risk levels, up to ten balls. + help pachinko Rectangle of pins; populated board; pick a top slot; DOUBLE chains prizes. help cash hunt Reveal 100 prizes, mix their positions, pick one square. help video poker Five cabinet types, selectable games, toggle holds and bonuses. help pai gow poker Seven cards, two hands; Classic, EZ, No Push and Fortune/Envy. diff --git a/data/help/pachinko.yaml b/data/help/pachinko.yaml new file mode 100644 index 0000000..9147038 --- /dev/null +++ b/data/help/pachinko.yaml @@ -0,0 +1,53 @@ +name: pachinko +aliases: [pachinko, pachinko machine] +category: Interaction +related: [casino, plinko] +description: | + PACHINKO — a rectangle of pins. Pick your drop. + + play pachinko Take the single seat. + bet 10 Commit 10 credits and reveal the board. + 5 / drop 5 Choose top slot 5 and drop the ball. + bet / rebet Repeat your last wager and deal a new board. + table Show a blank board and the pay table. + stop Leave once no ball is in flight. + + Every bet deals a fresh board of eight pockets and shows it before you + choose. Seven pockets are filler prizes drawn from 0.1x to 1.5x, mostly + 0.5x and below, so the board is always busy. The eighth is a guaranteed + premium above 1.5x: usually 1.7x or 2x, occasionally 5x or 10x, and + about one board in 900 carries the 100x jackpot. Multipliers at or above + 2x are whole numbers; below 2x they move in 0.1x steps. A DOUBLE appears + on about one board in twenty. + + Filler prizes always return something, so a board is never a wall of + zeroes. Small fractions (0.1x, 0.5x) are the common texture; the + guaranteed premium is the reason to aim carefully. + + Eight numbered top slots crown the board. A drop is 20 fair 50:50 + bounces; the side walls reflect the ball, so the distribution is widest + in the middle and piles up against the walls. Because you see the board + first, the best slot moves from board to board. The pins stagger from + row to row, so even a ball that holds its lane visibly weaves between + them. + + DOUBLE doubles EVERY prize on the board and drops the ball again for + free from any slot. The DOUBLE space stays where it is, so the doubling + can chain without limit: 2x becomes 4x, 8x, 16x... for as long as you + keep landing on it. The board grows hotter and brighter with each hit. + + Multipliers INCLUDE your stake: 0.1x returns a tenth, 0.5x returns half, + 2x returns double, and the 100x jackpot returns one hundred times + (before any DOUBLE). + + Return is measured for a player who always chooses the best top slot: + exactly 100% — the house makes its money on slot choice, not on the + board. Dropping from a uniformly random slot returns about 80%. The table + command shows a blank board and the pay table; a House Edge instrument + reveals the exact odds. Fractional credits round without bias so the + average holds even for one-credit bets. + + You cannot leave or re-bet mid-board. Shutdown refunds a committed + board; disconnecting forfeits an unfinished drop. A payout that cannot + be persisted keeps its stake pending for recovery rather than claiming + a paid win. Returns that would overflow the economy are capped. diff --git a/data/help/plinko.yaml b/data/help/plinko.yaml index 486a87b..a55acfd 100644 --- a/data/help/plinko.yaml +++ b/data/help/plinko.yaml @@ -1,7 +1,7 @@ name: plinko aliases: [difficulty, balls] category: Interaction -related: [casino] +related: [casino, pachinko, cash hunt] description: | PLINKO — let it rain! diff --git a/data/rooms/casino/6201.yaml b/data/rooms/casino/6201.yaml index 9708f89..c1984d8 100644 --- a/data/rooms/casino/6201.yaml +++ b/data/rooms/casino/6201.yaml @@ -1,8 +1,10 @@ name: The Kaleidoscope Vault -description: One hundred jewel-lit shutters cover a wall of polished midnight glass. Behind each waits a prize, from an empty pocket to a fortune bright enough to light the entire casino. When the vault shuffles, ribbons of violet and gold sweep across the sealed squares. A small plaque reads, "One choice. Your fortune." The Crimson Rain Plinko Salon lies south. +description: One hundred jewel-lit shutters cover a wall of polished midnight glass. Behind each waits a prize, from an empty pocket to a fortune bright enough to light the entire casino. When the vault shuffles, ribbons of violet and gold sweep across the sealed squares. A small plaque reads, "One choice. Your fortune." A tall Pachinko gallery rises to the north; the Crimson Rain Plinko Salon lies south. exits: south: room: 6200 + north: + room: 6202 casino_tables: - id: cash_hunt game: cash_hunt diff --git a/data/rooms/casino/6202.yaml b/data/rooms/casino/6202.yaml new file mode 100644 index 0000000..0ae636b --- /dev/null +++ b/data/rooms/casino/6202.yaml @@ -0,0 +1,10 @@ +name: Fallen Star Pachinko Gallery +description: A broad cabinet of polished obsidian frames a tall rectangle of silver pins. Numbered brass slots crown the top, a golden DOUBLE burns at the base, and jewel-bright prize lamps ring the pockets. Balls rain down in ribbons of light, bouncing pin to pin. The Kaleidoscope Vault glitters to the south. +exits: + south: + room: 6201 +casino_tables: + - id: pachinko + game: pachinko + min_bet: 1 + max_bet: 10000 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 |
