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": // A blank line on either side keeps the wheel from crowding the prose. return "\n" + renderRouletteWheel(s.Wheel, mode, unicode) + "\n" case "result": return "\n" + renderRouletteResult(s.Pockets, mode, unicode, size) + "\n" default: // A blank line above the tracker separates it from the prose or menu // that precedes the board. return "\n" + renderRouletteTracker(s, mode, unicode) + "\n" + renderRouletteLayout(s.DoubleZero, s.Racetrack, 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) } // rouletteWheelBlack is the spinning wheel's black pocket color. Plain black // background disappears on dark terminals, so the wheel uses a very dark blue // purely for visibility; the betting board and result cards keep true black. const rouletteWheelBlack = 17 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 } // renderRouletteRacetrack draws the European wheel clockwise with 26 at // the right-hand tip. Interior boundaries meet the pockets covered by each // call bet; the bowed Zero boundary denotes its overlap with Voisins. func renderRouletteRacetrack(mode string, unicode bool) string { b := rouletteBorders(unicode) tl, tr, bl, br := "/", "\\", "\\", "/" if unicode { tl, tr, bl, br = "╭", "╮", "╰", "╯" } const width, height = 73, 11 grid := make([][]rouletteCell, height) for y := range grid { grid[y] = make([]rouletteCell, width) for x := range grid[y] { grid[y][x] = rouletteCell{' ', -1} } } put := func(x, y int, text string, bg int) { for _, r := range text { grid[y][x] = rouletteCell{r, bg} x++ } } ink := func(x, y int, text string) { put(x, y, text, -1) } wheel := casino.WheelOrder(false) pocket := func(x, y, index, width int) { n := wheel[index%len(wheel)] bg, name := roulettePocketColor(n) if mode == "none" || mode == "" { n += name[:1] } put(x, y, rouletteCenter(n, width), bg) } // Equal-width bands, but slightly wider Orphelins cells below: five // pockets (1,20,14,31,9) above face three (6,34,17) below. band := func(y, start, step int, widths []int) { x := 4 ink(x, y, b.v) for i, w := range widths { ink(x, y-1, b.teeTop+strings.Repeat(b.h, w-1)) ink(x, y+1, b.teeBottom+strings.Repeat(b.h, w-1)) pocket(x+1, y, start+i*step, w-1) x += w ink(x, y, b.v) } } band(1, 19, 1, []int{4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4}) band(9, 15, -1, []int{4, 4, 4, 4, 4, 5, 5, 6, 4, 4, 4, 4, 4, 4, 4}) ink(4, 0, tl) ink(68, 0, tr) ink(4, 10, bl) ink(68, 10, br) // Connected, stepped curves join the long bands to three end pockets. ink(1, 1, tl+strings.Repeat(b.h, 2)+b.teeRight) ink(68, 1, b.teeLeft+strings.Repeat(b.h, 2)+tr) ink(0, 2, tl+b.teeBottom+strings.Repeat(b.h, 2)+b.cross) ink(68, 2, b.cross+strings.Repeat(b.h, 2)+b.teeBottom+tr) ink(0, 8, bl+b.teeTop+strings.Repeat(b.h, 2)+b.cross) ink(68, 8, b.cross+strings.Repeat(b.h, 2)+b.teeTop+br) ink(1, 9, bl+strings.Repeat(b.h, 2)+b.teeRight) ink(68, 9, b.teeLeft+strings.Repeat(b.h, 2)+br) for i := 0; i < 3; i++ { y := 3 + 2*i for _, x := range []int{0, 4, 68, 72} { ink(x, y, b.v) } pocket(1, y, 18-i, 3) // 10,23,8 pocket(69, y, 35+i, 3) // 3,26,0 if i < 2 { ink(0, y+1, b.teeLeft+strings.Repeat(b.h, 3)+b.teeRight) ink(68, y+1, b.teeLeft+strings.Repeat(b.h, 3)+b.teeRight) } } // Connected steps meet 33/1 above and 27/6 below. Diagonal glyphs // only touch at cell corners and leave visible gaps in many fonts. ink(20, 3, b.v) ink(20, 4, b.bl+b.h+b.tr) ink(22, 5, b.v) ink(22, 6, b.bl+b.h+b.tr) ink(24, 7, b.v) for y := 3; y <= 7; y++ { ink(40, y, b.v) } for _, p := range [][2]int{{20, 2}, {24, 8}, {40, 2}, {40, 8}, {60, 2}, {60, 8}} { ink(p[0], p[1], b.cross) } // Zero includes 12,35,3,26,0,32,15, entirely within Voisins. ink(59, 3, tl+br) ink(58, 4, tl+br) ink(58, 5, b.v) ink(58, 6, bl+tr) ink(59, 7, bl+tr) // Labels are centered on the middle row; a label too wide to leave a blank // cell against its dividers is wrapped onto the rows above and below it. // Only the glyphs are written: the centering padding must not be painted // over the step and curve dividers that share these rows. labelX := func(left, right int, text string) int { width := right - left - 1 pad := max(0, width-color.VisibleLen(text)) return left + 1 + pad/2 } inkCenter := func(y, left, right int, text string) { ink(labelX(left, right, text), y, text) } for _, label := range []struct { left, right int text string }{ {4, 22, "Tiers du Cylindre"}, {22, 40, "Orphelins"}, {40, 58, "Voisins du Zero"}, {58, 68, "Jeu Zero"}, } { lines := rouletteWrapLabel(label.text, label.right-label.left-3) if len(lines) == 1 { inkCenter(5, label.left, label.right, lines[0]) continue } // Wrapped lines share the first line's left edge so the two words read // as one label instead of drifting apart under independent centering. x := labelX(label.left, label.right, lines[0]) for i, line := range lines { ink(x, 4+2*i, line) } } lines := make([]string, height) for y, row := range grid { lines[y] = renderRouletteRow(row, mode) } return strings.Join(lines, "\n") } // rouletteWrapLabel breaks a call-bet label on spaces so each line leaves a // blank cell against the track dividers. The caller knows the labels are short // enough to wrap onto at most two lines; a single over-long word is left whole // rather than hyphenated. func rouletteWrapLabel(text string, limit int) []string { words := strings.Fields(text) var lines []string cur := "" for _, word := range words { switch { case cur == "": cur = word case len(cur)+1+len(word) <= limit: cur += " " + word default: lines = append(lines, cur) cur = word } } if cur != "" { lines = append(lines, cur) } return lines } // 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, racetrack bool, mode string, unicode bool) string { // The call bets are a single-zero layout; never draw the track on an // American board even if a malformed snapshot asks for it. racetrack = racetrack && !double b := rouletteBorders(unicode) var lines []string if racetrack { lines = append(lines, renderRouletteRacetrack(mode, unicode)) } // 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 = "00" case double && row == 1: zero = "0" 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 = rouletteWheelBlack } 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 two physical neighbours on // each side 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 [5]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 == len(pockets)/2 && 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") } func (g *Game) renderCasinoRules(sess *net.Session, cfg casino.TableConfig) string { rules := g.colorize(sess, "casino_action", cfg.RulesText()) if cfg.Game == casino.GameCraps { snap := &casino.CrapsSnapshot{Phase: "waiting"} if g.Casino != nil && sess.Player != nil { if table := g.Casino.Table(sess.Player.RoomID, cfg.ID); table != nil { for _, event := range table.Status(sess.Player.Name) { if view, ok := event.Snapshot.(*casino.CrapsSnapshot); ok { snap = view break } } } } return rules + "\n" + g.renderCasinoSnapshot(sess, snap) } if cfg.Game == casino.GameRoulette { snap := &casino.RouletteSnapshot{DoubleZero: cfg.Roulette.DoubleZero, Racetrack: cfg.Roulette.Racetrack, Phase: "betting", Wheel: casino.WheelOrder(cfg.Roulette.DoubleZero)} // Rules views draw a betting board too; use the live table's history // rather than presenting an empty tracker after completed spins. if g.Casino != nil && sess.Player != nil { if table := g.Casino.Table(sess.Player.RoomID, cfg.ID); table != nil { for _, event := range table.Status(sess.Player.Name) { if view, ok := event.Snapshot.(*casino.RouletteSnapshot); ok { snap.History = view.History break } } } } rules += "\n" + g.renderCasinoSnapshot(sess, snap) + "\n" + g.rouletteReminder(sess, cfg) } return rules }