package game import ( "fmt" "strings" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/color" ) // A half-table: numbered boxes and the come/field/line rails on the left; // the shared centre-action bets on the right. Every region is a real box, // and shared edges are joined rather than pasted together as broken borders. const crapsFelt = 22 // #005f00: darker than ANSI green / the roulette felt. const ( crapsInk = iota crapsGold crapsRed crapsMuted crapsMarked crapsPuckOn crapsPuckOff crapsFire crapsAts ) var crapsPaint = []color.ColorSpec{ {Fg: 15, Bg: crapsFelt}, {Fg: 229, Bg: crapsFelt, Bold: true}, {Fg: 217, Bg: crapsFelt, Bold: true}, {Fg: 108, Bg: crapsFelt}, {Fg: 0, Bg: 220, Bold: true}, {Fg: 0, Bg: 15, Bold: true}, {Fg: 15, Bg: 0, Bold: true}, {Fg: 229, Bg: 124, Bold: true}, // fire tracker: yellow on red {Fg: 229, Bg: 24, Bold: true}, // ATS tracker: yellow on blue } type crapsCell struct { glyph rune edges uint8 ink int } type crapsCanvas struct{ cells [][]crapsCell } // fill paints one rectangular region with an ink without touching glyphs or // box edges, so a whole tracker section can share one background colour. func (c *crapsCanvas) fill(x1, y1, x2, y2, ink int) { for y := y1; y <= y2; y++ { if y < 0 || y >= len(c.cells) { continue } for x := x1; x <= x2; x++ { if x >= 0 && x < len(c.cells[y]) { c.cells[y][x].ink = ink } } } } // crapsCanvasWidth is the fixed felt width every craps render uses. const crapsCanvasWidth = 77 func newCrapsCanvas(h int) *crapsCanvas { c := &crapsCanvas{cells: make([][]crapsCell, h)} for y := range c.cells { c.cells[y] = make([]crapsCell, crapsCanvasWidth) } return c } func (c *crapsCanvas) box(x1, y1, x2, y2 int) { // N=1, E=2, S=4, W=8. Unioning edges makes all T/cross joints exact. for x := x1; x < x2; x++ { c.cells[y1][x].edges |= 2 c.cells[y1][x+1].edges |= 8 c.cells[y2][x].edges |= 2 c.cells[y2][x+1].edges |= 8 } for y := y1; y < y2; y++ { c.cells[y][x1].edges |= 4 c.cells[y+1][x1].edges |= 1 c.cells[y][x2].edges |= 4 c.cells[y+1][x2].edges |= 1 } } func (c *crapsCanvas) text(x, y int, s string, ink int) { for _, r := range s { if x >= 0 && x < len(c.cells[y]) { c.cells[y][x] = crapsCell{glyph: r, ink: ink} } x++ } } func (c *crapsCanvas) center(x1, x2, y int, s string, ink int) { c.text(x1+(x2-x1+1-color.VisibleLen(s))/2, y, s, ink) } func (c *crapsCanvas) render(mode string, unicode bool) string { joints := map[uint8]rune{3: '└', 6: '┌', 9: '┘', 12: '┐', 7: '├', 11: '┴', 13: '┤', 14: '┬', 15: '┼'} var out strings.Builder for _, row := range c.cells { var run strings.Builder ink := row[0].ink flush := func() { if run.Len() > 0 { out.WriteString(color.Render(mode, crapsPaint[ink], run.String())) run.Reset() } } for _, cell := range row { if cell.ink != ink { flush() ink = cell.ink } r := cell.glyph if r == 0 { r = ' ' if cell.edges != 0 { switch { case cell.edges&5 == 0: r = '─' case cell.edges&10 == 0: r = '│' default: r = joints[cell.edges] } if !unicode { switch r { case '─': r = '-' case '│': r = '|' default: r = '+' } } } } run.WriteRune(r) } flush() out.WriteByte('\n') } return out.String() } func crapsHas(ns []int, n int) bool { for _, v := range ns { if v == n { return true } } return false } // Die-face glyph, e.g. ⚀ for one, used only where a felt paints dice pictures: // the hardway pairs and horn combinations. Blobs come first so a die face // never lexes as 1–6 elsewhere. func crapsDie(n int) string { return string([]rune{'⚀', '⚁', '⚂', '⚃', '⚄', '⚅'}[n-1]) } // crapsDrawPuck paints the point puck: OFF on the layout, ON in the point's // number box. It is shared by the bank and variant felts. func crapsDrawPuck(c *crapsCanvas, x, y int, on, unicode bool) { ink, word := crapsPuckOff, "OFF" if on { ink, word = crapsPuckOn, "ON " } a, b, d := "+---+", "|"+word+"|", "+---+" if unicode { a, b, d = "╭───╮", "│"+word+"│", "╰───╯" } c.text(x, y, a, ink) c.text(x, y+1, b, ink) c.text(x, y+2, d, ink) } func renderCrapsTrackers(s *casino.CrapsSnapshot, mode string, unicode bool) string { c := newCrapsCanvas(12) // Section backgrounds first: fire red, ATS blue, yellow lettering on top. c.fill(0, 0, 76, 5, crapsFire) c.fill(0, 5, 76, 11, crapsAts) c.box(0, 0, 76, 2) c.box(0, 5, 38, 8) c.box(38, 5, 76, 8) c.text(3, 1, "FIRE", crapsFire) for i, n := range []int{4, 5, 6, 8, 9, 10} { ink, mark := crapsFire, " " if crapsHas(s.Made, n) { ink, mark = crapsMarked, "*" } c.text(15+i*9, 1, fmt.Sprintf("[%2d%s]", n, mark), ink) } bounds := []int{0, 25, 50, 76} for i, payout := range []string{"24:1", "249:1", "999:1"} { lo, hi := bounds[i], bounds[i+1] c.box(lo, 2, hi, 5) c.center(lo+1, hi-1, 3, fmt.Sprintf("%d DISTINCT POINTS", i+4), crapsFire) c.center(lo+1, hi-1, 4, payout, crapsFire) } c.text(2, 6, "ALL SMALL", crapsAts) c.text(40, 6, "TALL", crapsAts) for i, n := range []int{2, 3, 4, 5, 6, 8, 9, 10, 11, 12} { x := 12 + i*5 if i >= 5 { x = 48 + (i-5)*5 } ink, mark := crapsAts, " " if crapsHas(s.Seen, n) { ink, mark = crapsMarked, "*" } c.text(x, 6, fmt.Sprintf("%2d%s ", n, mark), ink) } c.center(1, 37, 7, "* = rolled", crapsAts) c.center(39, 75, 7, "* = rolled", crapsAts) for i, row := range [][2]string{{"SMALL", "34:1"}, {"ALL", "175:1"}, {"TALL", "34:1"}} { lo, hi := bounds[i], bounds[i+1] c.box(lo, 8, hi, 11) c.center(lo+1, hi-1, 9, row[0], crapsAts) c.center(lo+1, hi-1, 10, row[1], crapsAts) } return c.render(mode, unicode) } func renderCrapsFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string { c := newCrapsCanvas(26) c.box(0, 0, 14, 6) c.center(1, 13, 4, "DON'T COME", crapsInk) c.center(1, 13, 5, "BAR 12", crapsMuted) nums := []int{4, 5, 6, 8, 9, 10} numLabels := []string{"4", "5", "SIX", "8", "NINE", "10"} pays := []string{"9:5", "7:5", "7:6", "7:6", "7:5", "9:5"} if s.Point == 0 { crapsDrawPuck(c, 5, 1, false, unicode) } for i, n := range nums { x := 14 + i*6 c.box(x, 0, x+6, 6) c.center(x+1, x+5, 4, numLabels[i], crapsGold) c.center(x+1, x+5, 5, pays[i], crapsMuted) if s.Point == n { crapsDrawPuck(c, x+1, 1, true, unicode) } } c.box(0, 6, 50, 10) c.center(1, 49, 7, "PLACE / BUY / LAY ON THE NUMBERS ABOVE", crapsMuted) c.center(1, 49, 9, "C O M E", crapsRed) c.box(0, 10, 50, 16) c.center(1, 49, 11, "FIELD", crapsGold) c.center(1, 49, 13, "( 2 ) 3 4 9 10 11 ( 12 )", crapsInk) twelve := s.Field12 if twelve == 0 { twelve = 2 } twelvePay := "DOUBLE" if twelve == 3 { twelvePay = "TRIPLE" } c.center(1, 49, 15, fmt.Sprintf("2 PAYS DOUBLE 12 PAYS %s", twelvePay), crapsGold) c.box(0, 16, 50, 19) c.center(1, 49, 18, "DON'T PASS / BAR 12", crapsInk) c.box(0, 19, 50, 22) c.center(1, 49, 21, "PASS LINE", crapsGold) c.box(0, 22, 50, 25) c.center(1, 49, 24, "FREE ODDS / BEHIND THE LINE", crapsInk) // Centre action: four bonus spots, four hardways, six propositions, horn/hop. bonus := []struct { label, pays string }{{"FIRE", "24x"}, {"SMALL", "34:1"}, {"ALL", "175:1"}, {"TALL", "34:1"}} xs := []int{50, 56, 63, 69, 76} for i, spot := range bonus { c.box(xs[i], 0, xs[i+1], 5) c.center(xs[i]+1, xs[i+1]-1, 2, spot.label, crapsGold) c.center(xs[i]+1, xs[i+1]-1, 4, spot.pays, crapsMuted) } for i, n := range []int{4, 6, 8, 10} { x, y := 50+(i%2)*13, 5+(i/2)*4 c.box(x, y, x+13, y+4) c.center(x+1, x+12, y+1, fmt.Sprintf("HARD %d", n), crapsInk) pay := 7 if n == 6 || n == 8 { pay = 9 } pair := fmt.Sprintf("%d+%d", n/2, n/2) if unicode { pair = crapsDie(n/2) + "+" + crapsDie(n/2) } c.center(x+1, x+12, y+3, fmt.Sprintf("%s %d:1", pair, pay), crapsGold) } props := []string{"SEVEN", "CRAPS", "ELEVEN", "ACES", "ACE 3", "BOXCARS"} odds := []string{"4:1", "7:1", "15:1", "30:1", "15:1", "30:1"} px := []int{50, 59, 67, 76} for i, label := range props { col, y := i%3, 13+(i/3)*4 x, x2 := px[col], px[col+1] c.box(x, y, x2, y+4) c.center(x+1, x2-1, y+1, label, crapsRed) c.center(x+1, x2-1, y+3, odds[i], crapsInk) } c.box(50, 21, 63, 25) c.center(51, 62, 22, "HORN", crapsGold) horn := "2 3 11 12" if unicode { // Horn combinations as painted dice pairs: 2, 3, 11, 12. horn = strings.Join([]string{crapsDie(1) + crapsDie(1), crapsDie(1) + crapsDie(2), crapsDie(5) + crapsDie(6), crapsDie(6) + crapsDie(6)}, " ") } c.center(51, 62, 24, horn, crapsMuted) c.box(63, 21, 76, 25) c.center(64, 75, 22, "HOP", crapsGold) c.center(64, 75, 24, "15:1 / 30:1", crapsMuted) return c.render(mode, unicode) } // renderHighPointFelt draws High Point's number row, point puck and pass // line, with the come-out special case printed on the felt. func renderHighPointFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string { c := newCrapsCanvas(16) c.box(0, 0, 76, 15) c.center(1, 75, 1, "HIGH POINT CRAPS", crapsGold) c.center(1, 75, 2, "COME-OUT 2 OR 3: IGNORED | 11 OR 12: WINS", crapsInk) nums := []int{4, 5, 6, 8, 9, 10} labels := []string{"4", "5", "SIX", "8", "NINE", "10"} for i, n := range nums { x := 2 + i*12 c.box(x, 5, x+11, 10) c.center(x+1, x+10, 9, labels[i], crapsGold) if s.Point == n { crapsDrawPuck(c, x+3, 6, true, unicode) } } c.box(2, 12, 74, 14) c.center(3, 73, 13, "PASS LINE", crapsGold) return c.render(mode, unicode) } // renderCraplessFelt gives every total a point box and shows the pass rail; // crapless has no don't bets or bonuses. func renderCraplessFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string { c := newCrapsCanvas(16) c.box(0, 0, 76, 15) c.center(1, 75, 1, "CRAPLESS CRAPS", crapsGold) c.center(1, 75, 2, "COME-OUT 7 WINS | ALL OTHER TOTALS BECOME POINTS", crapsInk) nums := []int{2, 3, 4, 5, 6, 8, 9, 10, 11, 12} for i, n := range nums { x := 3 + i*7 c.box(x, 5, x+6, 10) c.center(x+1, x+5, 9, fmt.Sprint(n), crapsGold) if s.Point == n { crapsDrawPuck(c, x+1, 6, true, unicode) } } c.box(2, 12, 74, 14) c.center(3, 73, 13, "PASS LINE", crapsGold) return c.render(mode, unicode) } // renderNewYorkFelt is the pass/don't-pass, buy/lay layout. func renderNewYorkFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string { c := newCrapsCanvas(16) c.box(0, 0, 76, 15) c.center(1, 75, 1, "NEW YORK CRAPS", crapsGold) c.center(1, 75, 2, "PASS | DON'T PASS (BAR 12) | FREE ODDS | BUY / LAY 4 5 6 8 9 10", crapsInk) nums := []int{4, 5, 6, 8, 9, 10} for i, n := range nums { x := 3 + i*12 c.box(x, 5, x+10, 10) c.center(x+1, x+9, 9, fmt.Sprint(n), crapsGold) if s.Point == n { crapsDrawPuck(c, x+3, 6, true, unicode) } } c.box(2, 12, 74, 14) c.center(3, 73, 13, "PASS LINE / DON'T PASS", crapsGold) return c.render(mode, unicode) } // renderDieRichFelt shows the single-die come-out targets and the four point // faces, with the pass line beneath them. func renderDieRichFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string { c := newCrapsCanvas(15) c.box(0, 0, 76, 14) c.center(1, 75, 1, "DIE RICH CRAPS", crapsGold) c.center(1, 75, 2, "ONE DIE | COME-OUT 6 WINS, 1 LOSES | 2-5 BECOME THE POINT", crapsInk) nums := []int{2, 3, 4, 5} for i, n := range nums { x := 8 + i*16 c.box(x, 5, x+12, 10) c.center(x+1, x+11, 9, fmt.Sprint(n), crapsGold) if s.Point == n { crapsDrawPuck(c, x+4, 6, true, unicode) } } c.box(2, 11, 74, 13) c.center(3, 73, 12, "PASS LINE", crapsGold) return c.render(mode, unicode) } // renderCrapsDice paints each face as a white tile with black pips. The tile // is five interior rows tall (three pip rows plus white padding top and // bottom) inside a box outline, so the face reads as a square die. func renderCrapsDice(dice [2]int, mode string, unicode bool) string { if dice[0] < 1 || dice[0] > 6 || dice[1] < 0 || dice[1] > 6 { return "" } patterns := [][3]string{{}, {" ", " o ", " "}, {"o ", " ", " o"}, {"o ", " o ", " o"}, {"o o", " ", "o o"}, {"o o", " o ", "o o"}, {"o o", "o o", "o o"}} pip := "o" if unicode { pip = "●" } top, bottom, v := "+-------+", "+-------+", "|" if unicode { top, bottom, v = "╭───────╮", "╰───────╯", "│" } spec := color.ColorSpec{Fg: 0, Bg: 15, Bold: true} var out strings.Builder for row := 0; row < 7; row++ { out.WriteString(" ") for i, n := range dice { if n == 0 { continue } var line string switch row { case 0: line = top case 6: line = bottom case 1, 5: line = v + " " + v default: line = v + " " + strings.ReplaceAll(patterns[n][row-2], "o", pip) + " " + v } out.WriteString(color.Render(mode, spec, line)) if i == 0 { out.WriteString(" ") } } if row == 3 { out.WriteString(color.Render(mode, color.ColorSpec{Fg: 229, Bg: -1, Bold: true}, fmt.Sprintf(" Total: %d", dice[0]+dice[1]))) } out.WriteByte('\n') } return out.String() } func renderCrapsSnapshot(s *casino.CrapsSnapshot, mode string, unicode bool, styles ...string) string { if s == nil { return "" } style := "dark" if len(styles) > 0 { style = styles[0] } var out strings.Builder title := func(text string) string { return color.Render(mode, color.ColorSpec{Fg: 229, Bg: -1, Bold: true}, text) } subtle := func(text string) string { return color.Render(mode, color.ColorSpec{Fg: 250, Bg: -1}, text) } point := "COME-OUT" if s.Point != 0 { point = fmt.Sprintf("POINT %d", s.Point) } if s.Variant == "simplified" { // The title alone says it: simplified craps has no point phase. point = "" } variant := s.Variant if variant == "" { variant = "bank" } out.WriteString("\n" + title(" "+strings.ToUpper(strings.ReplaceAll(variant, "_", " "))+" CRAPS")) if point != "" { out.WriteString(subtle(" / " + point)) } out.WriteString("\n") if variant == "bank" || variant == "diceless" { out.WriteString(renderCrapsTrackers(s, mode, unicode)) out.WriteByte('\n') out.WriteString(renderCrapsFelt(s, mode, unicode)) } else if variant == "simplified" { out.WriteString(renderSimplifiedCrapsFelt(mode, unicode)) } else { switch variant { case "high_point": out.WriteString(renderHighPointFelt(s, mode, unicode)) case "crapless": out.WriteString(renderCraplessFelt(s, mode, unicode)) case "new_york": out.WriteString(renderNewYorkFelt(s, mode, unicode)) case "die_rich": out.WriteString(renderDieRichFelt(s, mode, unicode)) default: c := newCrapsCanvas(7) c.box(0, 0, 76, 6) c.center(1, 75, 3, "HOUSE EDITION — rules teaches this edition", crapsGold) out.WriteString(c.render(mode, unicode)) } } out.WriteByte('\n') if variant == "diceless" { out.WriteString(renderCrapsCards(s.Dice, mode, unicode, style)) } else { out.WriteString(renderCrapsDice(s.Dice, mode, unicode)) } if len(s.Queue) > 1 { out.WriteString(subtle(" On deck "+strings.Join(s.Queue[1:], " > ")) + "\n") } hint := "" if s.Phase != "waiting" && s.Phase != "" { hint = "" if s.Variant == "simplified" { if s.ShootIn > 0 { hint = fmt.Sprintf("The dice roll in %ds.", s.ShootIn) } } else if s.ShootIn > 0 { hint = fmt.Sprintf("Bets open. Shooter ready in %ds.", s.ShootIn) } else if s.DealerIn > 0 { hint = fmt.Sprintf("Shooter ready. Dealer rolls in %ds; bets still open.", s.DealerIn) } } if s.AutoRoll { hint = fmt.Sprintf("AUTOROLL / Shooter's next roll in %ds", s.AutoIn) } if hint != "" { out.WriteString(subtle(" "+hint) + "\n") } return out.String() }