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 crapsBonus crapsGoldInk crapsBonusInk crapsBonusDim ) var crapsPaint = []color.ColorSpec{ {Fg: 15, Bg: crapsFelt}, {Fg: 229, Bg: crapsFelt, Bold: true}, {Fg: 160, Bg: crapsFelt, Bold: true}, // deep red for come/pass/props {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: 24, Bold: true}, // Fire/ATS tracker: yellow on blue {Fg: 229, Bg: crapsFelt}, // regular-weight gold, used for field notes {Fg: 229, Bg: 24}, // regular-weight tracker text and brackets {Fg: 108, Bg: 24}, // dim tracker odds, matching the place/lay payouts } 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) } // crapsSpan is one color run inside a centered line. centerSpans lays the runs // end to end and centers their combined width, so a label can stay regular // weight while its payout is dim, or a heading can pair two inks on one row. type crapsSpan struct { text string ink int } func (c *crapsCanvas) centerSpans(x1, x2, y int, spans ...crapsSpan) { total := 0 for _, s := range spans { total += color.VisibleLen(s.text) } x := x1 + (x2-x1+1-total)/2 for _, s := range spans { c.text(x, y, s.text, s.ink) x += color.VisibleLen(s.text) } } // crapsJoints maps a union of box edges (N=1, E=2, S=4, W=8) to its glyph. var crapsJoints = map[uint8]rune{3: '└', 6: '┌', 9: '┘', 12: '┐', 7: '├', 11: '┴', 13: '┤', 14: '┬', 15: '┼'} // crapsEdgeRune is the box glyph for a cell's edges, downgraded to ASCII when // unicode is off. func crapsEdgeRune(cell crapsCell, unicode bool) rune { if cell.edges == 0 { return ' ' } var r rune switch { case cell.edges&5 == 0: r = '─' case cell.edges&10 == 0: r = '│' default: r = crapsJoints[cell.edges] } if !unicode { switch r { case '─': r = '-' case '│': r = '|' default: r = '+' } } return r } func (c *crapsCanvas) render(mode string, unicode bool) string { 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 = crapsEdgeRune(cell, unicode) } 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(7) // The whole Fire/ATS tracker sits on one blue field. c.fill(0, 0, 76, 6, crapsBonus) c.box(0, 0, 76, 2) c.box(0, 4, 38, 6) c.box(38, 4, 76, 6) c.text(3, 1, "FIRE", crapsBonus) for i, n := range []int{4, 5, 6, 8, 9, 10} { crapsTrackerPoint(c, 15+i*9, 1, n, crapsHas(s.Made, n)) } 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, 4) c.centerSpans(lo+1, hi-1, 3, crapsSpan{fmt.Sprintf("%d POINTS", i+4), crapsBonusInk}, crapsSpan{" " + payout, crapsBonusDim}) } c.text(2, 5, "SMALL", crapsBonus) c.text(40, 5, "TALL", crapsBonus) 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 } crapsTrackerPoint(c, x, 5, n, crapsHas(s.Seen, n)) } return c.render(mode, unicode) } // crapsTrackerPoint draws one bracketed total for the Fire/ATS tracker. The // brackets stay regular weight while the number itself is bold; a made or // seen point keeps its solid highlight across the whole token. func crapsTrackerPoint(c *crapsCanvas, x, y, n int, marked bool) { if marked { c.text(x, y, fmt.Sprintf("[%2d]", n), crapsMarked) return } c.text(x, y, "[", crapsBonusInk) c.text(x+1, y, fmt.Sprintf("%2d", n), crapsBonus) c.text(x+3, y, "]", crapsBonusInk) } func renderCrapsFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string { // Compact half-table: every rail keeps its label and value on adjacent // rows, so the felt is five rows shorter than the spaced-out layout. c := newCrapsCanvas(21) 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, 14) c.center(1, 49, 11, "F I E L D", crapsGold) c.center(1, 49, 12, "( 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, 13, fmt.Sprintf("2 PAYS DOUBLE 12 PAYS %s", twelvePay), crapsGoldInk) c.box(0, 14, 50, 16) c.centerSpans(1, 49, 15, crapsSpan{"DON'T PASS", crapsInk}, crapsSpan{" BAR 12", crapsMuted}) c.box(0, 16, 50, 18) c.center(1, 49, 17, "PASS LINE", crapsRed) c.box(0, 18, 50, 20) odds := s.Odds if odds == "" { odds = "3x-4x-5x" } c.centerSpans(1, 49, 19, crapsSpan{"FREE ODDS", crapsInk}, crapsSpan{" (" + odds + ")", crapsMuted}) // Centre action: four bonus spots, four hardways, six propositions, horn/hop. // The Fire and ATS spots share one blue field. 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 := range bonus { c.box(xs[i], 0, xs[i+1], 5) } c.fill(50, 0, 76, 5, crapsBonus) for i, spot := range bonus { c.center(xs[i]+1, xs[i+1]-1, 2, spot.label, crapsBonus) c.center(xs[i]+1, xs[i+1]-1, 4, spot.pays, crapsBonusDim) } for i, n := range []int{4, 6, 8, 10} { x, y := 50+(i%2)*13, 5+(i/2)*3 c.box(x, y, x+13, y+3) 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.centerSpans(x+1, x+12, y+2, crapsSpan{pair + " ", crapsGold}, crapsSpan{fmt.Sprintf("%d:1", pay), crapsMuted}) } props := []string{"SEVEN", "CRAPS", "ELEVEN", "ACES", "ACE 3", "BOXCARS"} oddsRows := []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, 11+(i/3)*3 x, x2 := px[col], px[col+1] c.box(x, y, x2, y+3) c.center(x+1, x2-1, y+1, label, crapsRed) c.center(x+1, x2-1, y+2, oddsRows[i], crapsMuted) } c.box(50, 17, 63, 20) c.center(51, 62, 18, "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, 19, horn, crapsMuted) c.box(63, 17, 76, 20) c.center(64, 75, 18, "HOP", crapsGold) c.center(64, 75, 19, "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, 7, 8, 9, 10} labels := []string{"4", "5", "SIX", "7", "8", "NINE", "10"} for i, n := range nums { x := 3 + i*10 c.box(x, 5, x+10, 10) c.center(x+1, x+9, 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 uses half-block caps and white backgrounds to make a // square tile in a terminal's roughly 1:2 character cells. Pips are inset; // monochrome clients get an outlined face rather than disconnected dots. 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 := [7][3]string{ {}, {" ", " ● ", " "}, {" ● ", " ", " ● "}, {" ● ", " ● ", " ● "}, {" ● ● ", " ", " ● ● "}, {" ● ● ", " ● ", " ● ● "}, {" ● ● ", " ● ● ", " ● ● "}, } colored := mode != "" && mode != "none" face := color.ColorSpec{Fg: 0, Bg: 15} cap := color.ColorSpec{Fg: 15, Bg: -1} var out strings.Builder for row := 0; row < 5; row++ { out.WriteString(" ") for i, n := range dice { if n == 0 { continue } if i > 0 { out.WriteString(" ") } line := "" spec := face if row == 0 || row == 4 { line = " " if unicode && colored { line, spec = "▄▄▄▄▄▄▄", cap if row == 4 { line = "▀▀▀▀▀▀▀" } } else if !colored { line = "+-----+" } } else { line = patterns[n][row-1] if !colored { line = "|" + strings.TrimPrefix(strings.TrimSuffix(line, " "), " ") + "|" } if !unicode { line = strings.ReplaceAll(line, "●", "o") } } out.WriteString(color.Render(mode, spec, line)) } if row == 2 { 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 %dt.", s.ShootIn) } } else if s.ShootIn > 0 { hint = fmt.Sprintf("Bets open. Shooter ready in %dt.", s.ShootIn) } else if s.DealerIn > 0 { hint = fmt.Sprintf("Shooter ready. Dealer rolls in %dt; bets still open.", s.DealerIn) } } if s.AutoRoll { hint = fmt.Sprintf("AUTOROLL / Shooter's next roll in %dt", s.AutoIn) } if hint != "" { out.WriteString(subtle(" "+hint) + "\n") } return out.String() }