aboutsummaryrefslogtreecommitdiff
path: root/internal/game/render_roulette.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/render_roulette.go')
-rw-r--r--internal/game/render_roulette.go333
1 files changed, 333 insertions, 0 deletions
diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go
new file mode 100644
index 0000000..95e91ff
--- /dev/null
+++ b/internal/game/render_roulette.go
@@ -0,0 +1,333 @@
+package game
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/net"
+)
+
+// Roulette art is rendered per recipient, just like cards. Never embed ANSI
+// in engine snapshots: spectators may have different color/Unicode settings.
+func renderRouletteSnapshot(s *casino.RouletteSnapshot, mode string, unicode bool, size int) string {
+ switch s.Phase {
+ case "playing":
+ return renderRouletteWheel(s.Wheel, mode, unicode)
+ case "result":
+ return renderRouletteResult(s.Pockets, mode, unicode, size)
+ default:
+ return renderRouletteLayout(s.DoubleZero, mode, unicode)
+ }
+}
+
+func rouletteInk(mode, text string) string {
+ return color.Render(mode, color.ColorSpec{Fg: 15, Bg: -1}, text)
+}
+
+// Use the basic palette so both ANSI-16 and xterm256 give white-on-red,
+// white-on-green and white-on-black, rather than approximating a gradient.
+func roulettePaint(mode, text string, bg int) string {
+ return color.Render(mode, color.ColorSpec{Fg: 15, Bg: bg, Bold: true}, text)
+}
+
+func roulettePocketColor(pocket string) (int, string) {
+ if pocket == "0" || pocket == "00" {
+ return 2, "GREEN"
+ }
+ n, _ := strconv.Atoi(pocket)
+ switch n {
+ case 1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36:
+ return 1, "RED"
+ default:
+ return 0, "BLACK"
+ }
+}
+
+func rouletteCenter(text string, width int) string {
+ pad := max(0, width-color.VisibleLen(text))
+ return strings.Repeat(" ", pad/2) + text + strings.Repeat(" ", pad-pad/2)
+}
+
+type rouletteLines struct{ h, v, tl, tr, bl, br, teeTop, teeBottom, teeLeft, teeRight, cross string }
+
+func rouletteBorders(unicode bool) rouletteLines {
+ if unicode {
+ return rouletteLines{"─", "│", "┌", "┐", "└", "┘", "┬", "┴", "├", "┤", "┼"}
+ }
+ return rouletteLines{"-", "|", "+", "+", "+", "+", "+", "+", "+", "+", "+"}
+}
+
+func repeatRoulette(s string, n int) []string {
+ parts := make([]string, n)
+ for i := range parts {
+ parts[i] = s
+ }
+ return parts
+}
+
+// renderRouletteLayout draws the betting board. Green is reserved for the
+// 0/00 lane: every other cell is red, black, or plain. The odds reminder is
+// not part of the board (it is shown by the "table" command), so the board
+// stays compact enough to sit in a betting menu.
+func renderRouletteLayout(double bool, mode string, unicode bool) string {
+ b := rouletteBorders(unicode)
+ var lines []string
+ title := "ROULETTE / SINGLE ZERO"
+ if double {
+ title = "ROULETTE / DOUBLE ZERO"
+ }
+ lines = append(lines, rouletteInk(mode, title))
+ // Five columns for the zero lane, twelve four-column pockets. Four
+ // adjacent pockets line up exactly over each 19-column dozen.
+ gridRule := func(left, joint, right string) string {
+ return left + strings.Repeat(b.h, 5) + joint + strings.Join(repeatRoulette(strings.Repeat(b.h, 4), 12), joint) + right
+ }
+ lines = append(lines, rouletteInk(mode, gridRule(b.tl, b.teeTop, b.tr)))
+ for row := 3; row >= 1; row-- {
+ zero, zeroBg := "", -1
+ switch {
+ case double && row == 3:
+ zero, zeroBg = "0", 2
+ case double && row == 1:
+ zero, zeroBg = "00", 2
+ case !double && row == 2:
+ zero, zeroBg = "0", 2
+ }
+ if zeroBg == 2 && (mode == "none" || mode == "") {
+ zero += "G"
+ }
+ line := rouletteInk(mode, b.v) + roulettePaint(mode, rouletteCenter(zero, 5), zeroBg) + rouletteInk(mode, b.v)
+ for n := row; n <= 36; n += 3 {
+ pocket := strconv.Itoa(n)
+ bg, name := roulettePocketColor(pocket)
+ if mode == "none" || mode == "" {
+ pocket += name[:1]
+ }
+ line += roulettePaint(mode, rouletteCenter(pocket, 4), bg) + rouletteInk(mode, b.v)
+ }
+ line += rouletteInk(mode, fmt.Sprintf(" column%d", row))
+ lines = append(lines, line)
+ if row > 1 {
+ // The zero lane spans the grid height; only its labeled cell is green.
+ line = rouletteInk(mode, b.v+" "+b.teeLeft+strings.Join(repeatRoulette(strings.Repeat(b.h, 4), 12), b.cross)+b.teeRight)
+ lines = append(lines, line)
+ }
+ }
+ lines = append(lines, rouletteInk(mode, gridRule(b.bl, b.teeBottom, b.br)))
+ // Outside bets share the exact 60-column numbered-grid footprint.
+ indent := " "
+ dozen := indent + rouletteInk(mode, b.v)
+ for i := 1; i <= 3; i++ {
+ dozen += rouletteInk(mode, rouletteCenter(fmt.Sprintf("dozen%d", i), 19)) + rouletteInk(mode, b.v)
+ }
+ lines = append(lines, dozen)
+ line := indent + rouletteInk(mode, b.v)
+ for _, bet := range []string{"low", "even", "red", "black", "odd", "high"} {
+ bg := -1
+ if bet == "red" {
+ bg = 1
+ }
+ if bet == "black" {
+ bg = 0
+ }
+ line += roulettePaint(mode, rouletteCenter(bet, 9), bg) + rouletteInk(mode, b.v)
+ }
+ lines = append(lines, line, rouletteInk(mode, indent+b.bl+strings.Repeat(b.h, 59)+b.br))
+ if mode == "none" || mode == "" {
+ lines = append(lines, "R = red, B = black, G = green")
+ }
+ return strings.Join(lines, "\n")
+}
+
+// rouletteWheelSlots are the pocket anchor points (clockwise from the top) for
+// the single-zero ring. The double-zero ring inserts one extra slot at the
+// bottom centre. This hand-tuned geometry is what keeps a full 37/38-pocket
+// circle readable in a monospace terminal.
+var rouletteWheelSlots = [][2]int{
+ {25, 0}, {33, 0}, {41, 0}, {46, 1}, {50, 2}, {53, 3}, {57, 4}, {59, 6}, {59, 7},
+ {60, 9}, {61, 11}, {60, 13}, {58, 15}, {58, 16}, {56, 18}, {54, 19}, {50, 20},
+ {46, 21}, {41, 22}, {33, 22}, {25, 22}, {20, 21}, {17, 20}, {13, 19}, {9, 18},
+ {7, 17}, {5, 16}, {4, 14}, {3, 12}, {3, 10}, {4, 8}, {7, 6}, {9, 5}, {12, 4},
+ {14, 3}, {17, 2}, {21, 1},
+}
+
+func rouletteWheelSlotsFor(double bool) [][2]int {
+ slots := append([][2]int(nil), rouletteWheelSlots...)
+ if !double {
+ return slots
+ }
+ insert := 20 // between {33,22} and {25,22}
+ slots = append(slots, [2]int{})
+ copy(slots[insert+1:], slots[insert:])
+ slots[insert] = [2]int{29, 22}
+ return slots
+}
+
+// roulettePocketToken renders one three-column pocket. With color it is just
+// the number; without color a trailing letter keeps red/black/green distinct.
+func roulettePocketToken(value, label, mode string) string {
+ if mode == "none" || mode == "" {
+ switch len(value) {
+ case 1:
+ return value + label[:1] + " "
+ case 2:
+ return value + label[:1]
+ default:
+ return value
+ }
+ }
+ return rouletteCenter(value, 3)
+}
+
+type rouletteCell struct {
+ r rune
+ bg int
+}
+
+// renderRouletteWheel draws a large circular wheel. The pocket count carries
+// the real zero count (one green for single zero, two for American), and the
+// hub shows the ball during the spin.
+func renderRouletteWheel(wheel []string, mode string, unicode bool) string {
+ double := false
+ for _, p := range wheel {
+ if p == "00" {
+ double = true
+ }
+ }
+ slots := rouletteWheelSlotsFor(double)
+ if len(wheel) != len(slots) {
+ slots = rouletteWheelSlots
+ if len(wheel) > len(slots) {
+ wheel = wheel[:len(slots)]
+ }
+ }
+ const width, height = 68, 23
+ grid := make([][]rouletteCell, height)
+ for y := range grid {
+ grid[y] = make([]rouletteCell, width)
+ for x := range grid[y] {
+ grid[y][x] = rouletteCell{' ', -1}
+ }
+ }
+ place := func(x, y int, s string, bg int) {
+ for k, r := range []rune(s) {
+ px := x + k
+ if px >= 0 && px < width && y >= 0 && y < height {
+ grid[y][px] = rouletteCell{r, bg}
+ }
+ }
+ }
+ for i, value := range wheel {
+ if i >= len(slots) {
+ break
+ }
+ bg, label := roulettePocketColor(value)
+ place(slots[i][0]-1, slots[i][1], roulettePocketToken(value, label, mode), bg)
+ }
+ hub := []string{"\\ | /", " \\ | / ", "--( o )--", " / | \\ ", "/ | \\"}
+ if unicode {
+ hub = []string{"╲ │ ╱", " ╲ │ ╱ ", "──( ● )──", " ╱ │ ╲ ", "╱ │ ╲"}
+ }
+ for k, text := range hub {
+ place(29, 9+k, text, -1)
+ }
+ lines := make([]string, 0, height)
+ for _, row := range grid {
+ lines = append(lines, renderRouletteRow(row, mode))
+ }
+ return strings.Join(lines, "\n")
+}
+
+func renderRouletteRow(row []rouletteCell, mode string) string {
+ last := len(row)
+ for last > 0 && row[last-1].r == ' ' {
+ last--
+ }
+ if last == 0 {
+ return ""
+ }
+ var sb strings.Builder
+ for i := 0; i < last; {
+ bg := row[i].bg
+ j := i
+ var seg []rune
+ for j < last && row[j].bg == bg {
+ seg = append(seg, row[j].r)
+ j++
+ }
+ text := string(seg)
+ if bg >= 0 && mode != "none" && mode != "" {
+ sb.WriteString(roulettePaint(mode, text, bg))
+ } else {
+ sb.WriteString(rouletteInk(mode, text))
+ }
+ i = j
+ }
+ return sb.String()
+}
+
+// renderRouletteResult draws the winning pocket and its two physical
+// neighbours at playing-card size. The ball marks the middle pocket; there is
+// no surrounding prose.
+func renderRouletteResult(pockets [3]string, mode string, unicode bool, size int) string {
+ size = cardRenderSize(unicode, size)
+ if size < 5 {
+ size = 5
+ }
+ width := size + 2
+ b := rouletteBorders(unicode)
+ ball := "o"
+ if unicode {
+ ball = "●"
+ }
+ var lines []string
+ for row := 0; row < size; row++ {
+ var cells []string
+ for i, pocket := range pockets {
+ bg, name := roulettePocketColor(pocket)
+ var text string
+ switch row {
+ case 0:
+ text = b.tl + strings.Repeat(b.h, width-2) + b.tr
+ case size - 1:
+ text = b.bl + strings.Repeat(b.h, width-2) + b.br
+ default:
+ content := ""
+ if row == 1 {
+ content = name
+ }
+ if row == size/2 {
+ content = pocket
+ }
+ if i == 1 && row == size/2+1 {
+ content = ball
+ }
+ text = b.v + rouletteCenter(content, width-2) + b.v
+ }
+ cells = append(cells, roulettePaint(mode, text, bg))
+ }
+ lines = append(lines, strings.Join(cells, " "))
+ }
+ return strings.Join(lines, "\n")
+}
+
+// rouletteReminder is the odds/cheat-sheet block, shown only by "table" (and
+// "table rules"), never with the ambient betting board.
+func (g *Game) rouletteReminder(sess *net.Session) string {
+ text := "Numbers 35:1 | Dozens / columns 2:1 | Other outside bets 1:1\n" +
+ "Low: 1-18 | High: 19-36 | Zero loses all outside bets\n" +
+ "bet 10 on 17 / bet 10 on red / bet 10 on dozen2"
+ return g.colorize(sess, "casino_action", text)
+}
+
+func (g *Game) renderCasinoRules(sess *net.Session, cfg casino.TableConfig) string {
+ rules := g.colorize(sess, "casino_action", cfg.RulesText())
+ if cfg.Game == casino.GameRoulette {
+ snap := &casino.RouletteSnapshot{DoubleZero: cfg.Roulette.DoubleZero, Phase: "betting", Wheel: casino.WheelOrder(cfg.Roulette.DoubleZero)}
+ rules += "\n" + g.renderCasinoSnapshot(sess, snap) + "\n" + g.rouletteReminder(sess)
+ }
+ return rules
+}