package game import ( "fmt" "math" "sort" "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 // zeroTL/zeroBL are the zero lane's outer corners. They are rounded while // the rest of the board keeps square joints, so the tall green zero panel // reads as a soft-edged block without disturbing the grid's connections. zeroTL, zeroBL 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, and a // six-column 2:1 lane on the right holding the row bets. Four adjacent // pockets line up exactly over each 19-column dozen. const rowCell = 6 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) + joint + strings.Repeat(b.h, rowCell) + right } // The numbered grid's bottom edge closes the row-bet cells and carries the // dozen-row dividers as downward stubs: crosses at the inner dozen // boundaries and where the grid meets the row lane, an upward tee // everywhere else. gridBottom := func() string { var sb strings.Builder sb.WriteString(b.zeroBL) for col := 6; col <= 66; col += 5 { if col == 6 { sb.WriteString(strings.Repeat(b.h, 5)) } else { sb.WriteString(strings.Repeat(b.h, 4)) } switch col { case 6, 26, 46, 66: sb.WriteString(b.cross) default: sb.WriteString(b.teeBottom) } } sb.WriteString(strings.Repeat(b.h, rowCell)) sb.WriteString(b.br) return sb.String() } lines = append(lines, rouletteInk(mode, gridRule(b.zeroTL, b.teeTop, b.tr))) for row := 3; row >= 1; row-- { zero := "" switch { case double && row == 3: zero = "0" case double && row == 1: zero = "00" case !double && row == 2: zero = "0" } if mode == "none" || mode == "" { zero += "G" } // The 0/00 lane is one green rectangle, so its blank cells above and // below the zeros are painted to match the pockets beside them. line := rouletteInk(mode, b.v) + roulettePaint(mode, rouletteCenter(zero, 5), 2) + 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) } // row1 is the top numbered line (3,6,...36) and row3 the bottom. line += rouletteInk(mode, rouletteCenter(fmt.Sprintf("row%d", 4-row), rowCell)+b.v) lines = append(lines, line) if row > 1 { // The zero lane is one green rectangle, so the two separator rows // are painted to match the cells above and below them. line = rouletteInk(mode, b.v) + roulettePaint(mode, " ", 2) + rouletteInk(mode, b.teeLeft+ strings.Join(repeatRoulette(strings.Repeat(b.h, 4), 12), b.cross)+ b.cross+strings.Repeat(b.h, rowCell)+b.teeRight) lines = append(lines, line) } } lines = append(lines, rouletteInk(mode, gridBottom())) // Outside bets share the exact 60-column numbered-grid footprint. The // dozen row gets its own bottom rule so the 1to12/13to24/25to36 boxes are // closed and do not run into the even-money spaces below them. indent := " " dozen := indent + rouletteInk(mode, b.v) for _, label := range []string{"1to12", "13to24", "25to36"} { dozen += rouletteInk(mode, rouletteCenter(label, 19)) + rouletteInk(mode, b.v) } lines = append(lines, dozen) dozenBottom := indent + b.teeLeft dozenJoints := []string{b.teeTop, b.cross, b.teeTop, b.cross, b.teeTop, b.teeRight} for i := 0; i < 6; i++ { dozenBottom += strings.Repeat(b.h, 9) + dozenJoints[i] } lines = append(lines, rouletteInk(mode, dozenBottom)) 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) } // The bottom rule tees upward under each outside-bet divider so the // vertical lines below the dozens meet the table's bottom edge. bottom := indent + b.bl for i := 0; i < 6; i++ { bottom += strings.Repeat(b.h, 9) if i < 5 { bottom += b.teeBottom } } bottom += b.br lines = append(lines, line, rouletteInk(mode, bottom)) if mode == "none" || mode == "" { lines = append(lines, "R = red, B = black, G = green") } return strings.Join(lines, "\n") } type rouletteCell struct { r rune bg int } // renderRouletteWheel draws a representative spinning wheel. There are no // numbers: only the rim carries solid red and black pockets, alternating like // a real wheel, with one green zero (plus its diametrically opposite pocket on // American wheels). A simple plus marks the croupier's spindle. Without color // the pockets fall back to R/B/G so the wheel still reads. func renderRouletteWheel(wheel []string, mode string, unicode bool) string { double := false for _, p := range wheel { if p == "00" { double = true } } const width, height = 17, 9 // Half-widths are hand-rounded so the 17x9 grid reads as a circle once the // terminal's roughly 2:1 character cells are taken into account. The top // and bottom rows stay wide instead of collapsing to a single cell. halfWidths := [height]int{2, 5, 7, 8, 8, 8, 7, 5, 2} cx, cy := float64(width-1)/2, float64(height-1)/2 rx, ry := cx, cy grid := make([][]rouletteCell, height) for y := range grid { grid[y] = make([]rouletteCell, width) for x := range grid[y] { grid[y][x] = rouletteCell{' ', -1} } } plain := mode == "none" || mode == "" pocketRune := func(bg int) rune { if !plain { return ' ' } switch bg { case 1: return 'R' case 2: return 'G' default: return 'B' } } // Collect the rim cells in angular order so the pockets can alternate // around the circumference instead of filling the disc with wedges. type rimCell struct { x, y int ang float64 } var rim []rimCell for y := 0; y < height; y++ { hw := halfWidths[y] for x := int(cx) - hw; x <= int(cx)+hw; x++ { nx := (float64(x) - cx) / rx ny := (float64(y) - cy) / ry if nx*nx+ny*ny >= 0.75 { rim = append(rim, rimCell{x, y, math.Atan2(ny, nx)}) } } } sort.Slice(rim, func(i, j int) bool { if rim[i].ang != rim[j].ang { return rim[i].ang < rim[j].ang } return rim[i].x < rim[j].x }) // The top and bottom rows hold an odd number of cells, so anchor the walk // there and leave those centre pockets one wide while every other pocket // takes two adjacent rim cells. topIdx, botIdx := -1, -1 for i, c := range rim { switch { case c.x == int(cx) && c.y == 0: topIdx = i case c.x == int(cx) && c.y == height-1: botIdx = i } } pocketIndex := make([]int, len(rim)) cur := 0 pocketIndex[topIdx] = cur for i := (topIdx + 1) % len(rim); i != topIdx; { cur++ pocketIndex[i] = cur if i == botIdx { i = (i + 1) % len(rim) continue } i = (i + 1) % len(rim) pocketIndex[i] = cur i = (i + 1) % len(rim) } pockets := cur + 1 greenPocket := 0 for i, pocket := range wheel { if pocket == "0" && len(wheel) > 0 { greenPocket = i * pockets / len(wheel) break } } opposite := (greenPocket + pockets/2) % pockets for i, c := range rim { p := pocketIndex[i] bg := 1 if p%2 == 1 { bg = 0 } if p == greenPocket || (double && p == opposite) { bg = 2 } grid[c.y][c.x] = rouletteCell{pocketRune(bg), bg} } // The croupier spins the wheel by its central spindle: a simple plus. plus := []string{ " | ", " | ", "---+---", " | ", " | ", } if unicode { plus = []string{ " │ ", " │ ", "───┼───", " │ ", " │ ", } } baseX, baseY := int(cx)-len([]rune(plus[0]))/2, int(cy)-len(plus)/2 for dy, row := range plus { for dx, r := range []rune(row) { if r == ' ' { continue } grid[baseY+dy][baseX+dx] = rouletteCell{r, -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 == ' ' && row[last-1].bg < 0 { 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 pocket number sits on the top row, a // rule separates it from the ball (one row above centre), and the color is // spelled out on the bottom row only when color is off. 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 = "●" } // The rule needs a spare row, so the smallest cards put the ball directly // under the number instead. const numberRow, ruleRow = 1, 2 ballRow := size / 2 hasRule := ballRow > ruleRow colorRow := size - 2 plain := mode == "none" || mode == "" 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 { case row == 0: text = b.tl + strings.Repeat(b.h, width-2) + b.tr case row == size-1: text = b.bl + strings.Repeat(b.h, width-2) + b.br case hasRule && row == ruleRow: text = b.teeLeft + strings.Repeat(b.h, width-2) + b.teeRight default: content := "" if row == numberRow { content = pocket } if i == 1 && row == ballRow { content = ball } if plain && row == colorRow { content = name } 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 | Split 17:1 | Street/trio 11:1 | Corner 8:1 | Line 5:1 | Dozens & rows 2:1 | Outside 1:1\n" + "Combos: split 1 2 | street 1 2 3 | corner 1 2 4 5 | line 1 2 3 4 5 6 | trio 0 1 2 | basket (double zero)\n" + "Low: 1-18 | High: 19-36 | Zero loses all outside bets\n" + "bet 10 on 17 / bet 10 on red / bet 10 on split 1 2" 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 }