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) } }