From e95b15ed51832e46c7d0d90f3c84d0ac0b65fbe3 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 9 Oct 2026 12:32:48 -0400 Subject: feat: pachinko slots randomized, 9th slot removed, RTP set to 100% if played perfectly --- internal/game/render_pachinko.go | 404 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 404 insertions(+) create mode 100644 internal/game/render_pachinko.go (limited to 'internal/game/render_pachinko.go') 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) + } +} -- cgit v1.2.3