package game import ( "fmt" "math" "strings" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/color" ) const plinkoWidth = 69 // plinkoRows is the board height (16 bounces, 17 pockets). const plinkoRows = 16 func plinkoBallColor(count, total int) int { shade := 255 if total > 1 && count > 1 { shade = 255 * (total - count) / (total - 1) } return color.NearestXterm256(255, shade, shade) } // plinkoGradientRGB is the board's shared hue: icy white at the center, // deepening through sky blue and violet to a saturated rose at the edges. func plinkoGradientRGB(x int) [3]int { stops := [4][3]int{{215, 255, 255}, {85, 195, 255}, {145, 85, 245}, {255, 0, 95}} distance := math.Abs(float64(x-34)) / 34 segment := min(2, int(distance*3)) blend := distance*3 - 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 plinkoColorAt(x int, scale float64) int { rgb := plinkoGradientRGB(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)))) } // plinkoHintRow is where the pocket reminders are overlaid. It sits two rows // below the midpoint so the letters clear the payout legend, which only runs // down to the midpoint row. const plinkoHintRow = plinkoRows/2 + 2 const plinkoHintColor = 238 // Foregrounds are deliberately faint so the balls stay the brightest thing // on the board. func plinkoPegColor(x int) int { return plinkoColorAt(x, 0.68) } func plinkoBorderColor(x int) int { return plinkoColorAt(x, 0.78) } func plinkoPocketColor(x int) int { return plinkoColorAt(x, 0.90) } func plinkoMultiplier(multiplier float64) string { return strings.TrimSuffix(fmt.Sprintf("%.1f", multiplier), ".0") + "x" } type plinkoCell struct { glyph rune fg int guide bool } type plinkoLine [plinkoWidth]plinkoCell func newPlinkoLine() plinkoLine { var line plinkoLine for x := range line { line[x] = plinkoCell{glyph: ' ', fg: -1} } return line } func (line *plinkoLine) text(x int, text string, fg int) { for _, c := range text { if x >= len(line) { break } line[x] = plinkoCell{glyph: c, fg: fg} x++ } } func (line plinkoLine) render(mode string) string { var b strings.Builder // Paint contiguous runs, not individual cells. This keeps the same fixed // canvas without flooding a slow MUD connection with redundant resets. for x := 0; x < len(line); { cell := line[x] end := x + 1 for end < len(line) && line[end].fg == cell.fg && line[end].guide == cell.guide { end++ } var text strings.Builder for _, c := range line[x:end] { text.WriteRune(c.glyph) } fg, dim := cell.fg, cell.guide && mode == "ansi" if dim { fg = 7 // Basic ANSI approximates the faint gray with dim white. } b.WriteString(color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Dim: dim}, text.String())) x = end } return b.String() } type plinkoLegendEntry struct { label rune text string // pocket is the left-most pocket index that carries this label, used to // tint the entry with that column's board hue. pocket int } // plinkoLegendEntries is one entry per distinct payout, outside-to-center. // Mirrored and rounded-tie pockets share a letter and therefore one entry. func plinkoLegendEntries(s *casino.PlinkoSnapshot) []plinkoLegendEntry { labels := casino.PlinkoPocketLabels(s.Pockets) var entries []plinkoLegendEntry seen := map[rune]bool{} for k, label := range labels { if seen[label] { continue } seen[label] = true entries = append(entries, plinkoLegendEntry{label: label, text: plinkoMultiplier(s.Pockets[k]), pocket: k}) } return entries } // plinkoLegendText pads an entry to a fixed width so a guide letter in the // next column never butts directly against the multiplier. func plinkoLegendText(e plinkoLegendEntry) string { return fmt.Sprintf("%c %-7s", e.label, e.text) } // The standalone paytable is compact text, not another board canvas. func renderPlinkoPaytable(s *casino.PlinkoSnapshot, mode string) string { balls := "balls" if s.Total == 1 { balls = "ball" } parts := []string{fmt.Sprintf("%s %d %s %.2f%% RTP:", s.Risk, s.Total, balls, s.RTP)} for _, e := range plinkoLegendEntries(s) { parts = append(parts, color.Render(mode, color.ColorSpec{Fg: plinkoPocketColor(2 + 4*e.pocket), Bg: -1}, fmt.Sprintf("%c%s", e.label, e.text))) } return strings.Join(parts, " ") } // plinkoOverlayHints prints the pocket reminders across the reminder row. The // letters sit on the fixed pocket columns (2+4k) so they line up with the base // rail. Hints only fill empty cells: a falling ball must never disappear. func plinkoOverlayHints(line *plinkoLine, labels [17]rune) { for k, label := range labels { x := 2 + 4*k if line[x].glyph == ' ' { line[x] = plinkoCell{glyph: label, fg: plinkoHintColor, guide: true} } } } // The legend appears down the left, then each snapshot appends one scanline. // The pocket reminders are overlaid on their own board row, not a line of their // own. func renderPlinko(s *casino.PlinkoSnapshot, mode string, unicode bool) string { labels := casino.PlinkoPocketLabels(s.Pockets) if s.Paytable { return renderPlinkoPaytable(s, mode) } ball, peg := 'o', '.' if unicode { ball, peg = '●', '·' } line := newPlinkoLine() left, right := 32-2*s.Row, 36+2*s.Row // Pegs sit at their true between-lane positions. for k := 1; k <= s.Row; k++ { x := left + 4*k line[x] = plinkoCell{glyph: peg, fg: plinkoPegColor(x)} } for k := 0; k <= s.Row; k++ { count := s.Counts[k] if count == 0 { continue } glyph := ball if mode == "none" && count > 1 { glyph = rune('0' + count) if count == 10 { glyph = '*' } } line[left+2+4*k] = plinkoCell{glyph: glyph, fg: plinkoBallColor(count, s.Total)} } line[left] = plinkoCell{glyph: '/', fg: plinkoBorderColor(left)} line[right] = plinkoCell{glyph: '\\', fg: plinkoBorderColor(right)} if entries := plinkoLegendEntries(s); s.Row < len(entries) { e := entries[s.Row] line.text(1, plinkoLegendText(e), plinkoPocketColor(2+4*e.pocket)) } if s.Row == plinkoHintRow { plinkoOverlayHints(&line, labels) } lines := []string{line.render(mode)} if s.Row == plinkoRows { rail := newPlinkoLine() for k, label := range labels { x := 1 + 4*k left, right := '[', ']' fg := plinkoPocketColor(x + 1) if s.Counts[k] > 0 { left, right = '>', '<' fg = plinkoBallColor(s.Counts[k], s.Total) } rail.text(x, string([]rune{left, label, right}), fg) } lines = append(lines, rail.render(mode)) } return strings.Join(lines, "\n") }