From 8b78aa4cd930fde145959704ba7bdc0e57465b9b Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sun, 4 Oct 2026 22:54:38 -0400 Subject: feat: better ux in cash hunt --- internal/casino/cash_hunt.go | 3 +- internal/game/cash_hunt_test.go | 114 +++++++++++++++++++++++++----- internal/game/casino.go | 6 +- internal/game/render_cash_hunt.go | 143 +++++++++++++++++++++++++++++++------- 4 files changed, 220 insertions(+), 46 deletions(-) (limited to 'internal') diff --git a/internal/casino/cash_hunt.go b/internal/casino/cash_hunt.go index 7c9752f..7c20a5a 100644 --- a/internal/casino/cash_hunt.go +++ b/internal/casino/cash_hunt.go @@ -56,6 +56,7 @@ type CashHuntSnapshot struct { Squares [CashHuntSquares]int // never populated while hidden Selected int // one-based; zero means not chosen Odds []CashHuntPrizeOdds + Edge HouseEdge // configured return; shown when a house-edge item is carried } func (*CashHuntSnapshot) snapshot() {} @@ -123,7 +124,7 @@ func (t *cashHuntTable) hints(n string) []MenuCommand { return t.hintsLocked() } func (t *cashHuntTable) snapLocked(phase string) *CashHuntSnapshot { - s := &CashHuntSnapshot{Phase: phase, Risk: cashHuntRisks[t.risk], Selected: t.selected} + s := &CashHuntSnapshot{Phase: phase, Risk: cashHuntRisks[t.risk], Selected: t.selected, Edge: t.Config.HouseEdge("")} if phase == "preview" || phase == "result" { s.Squares = t.squares } diff --git a/internal/game/cash_hunt_test.go b/internal/game/cash_hunt_test.go index 65ef16f..78d3e38 100644 --- a/internal/game/cash_hunt_test.go +++ b/internal/game/cash_hunt_test.go @@ -25,7 +25,7 @@ func TestCashHuntCommandsAndReceipt(t *testing.T) { g.executeBet(s, []string{"1"}, "bet 1") text := string(conn.data) preview, hidden := strings.Index(text, "PRIZE PREVIEW"), strings.Index(text, "hidden and mixed") - if preview < 0 || hidden <= preview || !strings.Contains(text, " 100 ") || len(s.Player.PendingWagers) != 1 { + if preview < 0 || hidden <= preview || !strings.Contains(text, cashHuntCenter("100", cashHuntTileWidth)) || len(s.Player.PendingWagers) != 1 { t.Fatalf("preview/shuffle sequence missing: %s", text) } if table.Busy(s.Player.Name) { @@ -114,32 +114,47 @@ func TestCashHuntBoardRendering(t *testing.T) { if phase == "result" { s.Selected = 100 } - rendered := renderCashHunt(s, mode, unicode) + rendered := renderCashHunt(s, mode, unicode, "98%") if mode == "none" && strings.Contains(rendered, "\x1b") { t.Fatal("plain board has ANSI escapes") } lines := strings.Split(stripANSI(rendered), "\n") - if len(lines) != 22 { - t.Fatalf("want two headers and 20 tile lines, got %d", len(lines)) + // One subtitle, a header top border + header row, a grid top + // separator, ten rows of (number, prize, separator), and a bottom + // border. + if len(lines) != 34 { + t.Fatalf("want a headed bordered 10x10 grid, got %d lines", len(lines)) } - for _, line := range lines[2:] { - if utf8.RuneCountInString(line) != 70 { - t.Fatalf("tile row is not 70 columns: %q", line) + for _, line := range lines[1:] { + if utf8.RuneCountInString(line) != cashHuntBoardWidth { + t.Fatalf("grid line is not %d columns: %q", cashHuntBoardWidth, line) } } - board := strings.Join(lines[2:], "\n") + board := strings.Join(lines[1:], "\n") if strings.ContainsAny(board, "[]") { t.Fatalf("square numbers must not use brackets: %q", board) } + header := lines[2] + if !strings.Contains(header, "HARD 98%") || !strings.Contains(header, "CASH HUNT") || !strings.Contains(header, "TOP PRIZE: 50000x") { + t.Fatalf("header row wrong: %q", header) + } + if unicode { + // The header is one merged cell, so its bottom border meets the + // grid columns with down-T junctions, not crosses. + if !strings.Contains(lines[3], "┬") || strings.Contains(lines[3], "┼") { + t.Fatalf("grid top border should use down-T junctions: %q", lines[3]) + } + } for row := 0; row < 10; row++ { - numberLine := lines[2+2*row] + numberRunes := []rune(lines[4+3*row]) for col := 0; col < 10; col++ { square := row*10 + col + 1 label := fmt.Sprintf("%d", square) if square == s.Selected { label = fmt.Sprintf(">%d<", square) } - got := numberLine[col*cashHuntTileWidth : (col+1)*cashHuntTileWidth] + start := 1 + col*(cashHuntTileWidth+1) + got := string(numberRunes[start : start+cashHuntTileWidth]) if want := cashHuntCenter(label, cashHuntTileWidth); got != want { t.Fatalf("square %d label: got %q want %q", square, got, want) } @@ -156,23 +171,86 @@ func TestCashHuntBoardRendering(t *testing.T) { } } if mode == "xterm256" { - // Dark rainbow columns replace the old black/blue checker. - if strings.Contains(rendered, "\x1b[48;5;16m") || strings.Contains(rendered, "\x1b[48;5;17m") { - t.Fatal("black/blue tile background survived") + // The whole board is black; the box-drawing grid supplies the + // separation. + if !strings.Contains(rendered, color.BgCode("xterm256", cashHuntTileBlack)) { + t.Fatal("missing black tile background") } - for _, shade := range cashHuntTileColors { - if !strings.Contains(rendered, color.BgCode("xterm256", shade)) { - t.Fatalf("missing dark rainbow shade %d", shade) + if strings.Contains(rendered, color.BgCode("xterm256", 234)) { + t.Fatal("checkerboard gray should be gone") + } + for _, shade := range []int{88, 94, 100, 28, 29, 30, 24, 18, 90, 89} { + if strings.Contains(rendered, color.BgCode("xterm256", shade)) { + t.Fatalf("rainbow tile shade %d should be gone", shade) } } + if !strings.Contains(rendered, color.FgCode("xterm256", 244)) { + t.Fatal("square numbers should be dimmer than the header") + } + if !strings.Contains(rendered, color.FgCode("xterm256", cashHuntBoxFg)) { + t.Fatal("grid characters should be darkened") + } + // 0x is not a prize: it stays dim and is never bold. + if !strings.Contains(board, cashHuntCenter("0x", cashHuntTileWidth)) { + t.Fatal("0x prize missing") + } + if strings.Contains(rendered, "\x1b[1m"+color.FgCode("xterm256", 237)) { + t.Fatal("0x should not render bold") + } + if !strings.Contains(rendered, "\x1b[1m"+color.FgCode("xterm256", 225)) { + t.Fatal("real multipliers should render bold") + } } } - hidden := renderCashHunt(&casino.CashHuntSnapshot{Phase: "hidden"}, mode, unicode) + hidden := renderCashHunt(&casino.CashHuntSnapshot{Phase: "hidden"}, mode, unicode, "") for _, line := range strings.Split(stripANSI(hidden), "\n") { - if utf8.RuneCountInString(line) != 70 { + if utf8.RuneCountInString(line) != cashHuntBoardWidth { t.Fatalf("shuffle banner width changed: %q", line) } } + if mode == "xterm256" { + if !strings.Contains(hidden, color.FgCode("xterm256", 196)) { + t.Fatal("hidden gradient should start on red") + } + if !strings.Contains(hidden, color.FgCode("xterm256", 129)) { + t.Fatal("hidden gradient should end on purple") + } + } } } } + +// TestCashHuntHeader pins the board header contents: difficulty left, title +// centered, top multiplier right, and the RTP readout only when a house-edge +// item was carried (the renderer receives an empty rtp otherwise). +func TestCashHuntHeader(t *testing.T) { + s := &casino.CashHuntSnapshot{Phase: "preview", Risk: "medium"} + s.Squares[0] = 50000 + + withRTP := stripANSI(renderCashHunt(s, "none", true, "98.00%")) + header := strings.Split(withRTP, "\n")[2] + if !strings.Contains(header, "MEDIUM 98.00%") || !strings.Contains(header, "CASH HUNT") || !strings.Contains(header, "TOP PRIZE: 50000x") { + t.Fatalf("header row wrong: %q", header) + } + + withoutRTP := stripANSI(renderCashHunt(s, "none", true, "")) + header = strings.Split(withoutRTP, "\n")[2] + if strings.Contains(header, "98") { + t.Fatalf("header showed RTP without a house-edge item: %q", header) + } +} + +// TestCashHuntHeaderRTPWithHouseEdgeItem verifies the full path: carrying a +// house-edge item makes the board header show the RTP beside the difficulty, +// at the item's precision. +func TestCashHuntHeaderRTPWithHouseEdgeItem(t *testing.T) { + g, s, conn := houseEdgeGame(t, "name: Vault\ncasino_tables:\n - id: cash_hunt\n game: cash_hunt\n") + s.Player.Options = map[string]any{"color": "none", "casino_hints": "off"} + writeHouseEdgeManual(t, g, s, 2) + g.executePlay(s, []string{"cash", "hunt"}, "play cash hunt") + g.executeBet(s, []string{"1"}, "bet 1") + text := stripANSI(string(conn.data)) + if !strings.Contains(text, "EASY 98.00%") { + t.Fatalf("header missing item RTP readout:\n%s", text) + } +} diff --git a/internal/game/casino.go b/internal/game/casino.go index 710090a..87b7176 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -813,7 +813,11 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot) size := sess.Player.OptionInt("card_size") switch s := snapshot.(type) { case *casino.CashHuntSnapshot: - return renderCashHunt(s, mode, unicode) + rtp := "" + if precision, ok := g.casinoEdgePrecision(sess); ok { + rtp = casinoRTP(s.Edge, precision) + } + return renderCashHunt(s, mode, unicode, rtp) case *casino.PlinkoSnapshot: return renderPlinko(s, mode, unicode) case *casino.PaiGowSnapshot: diff --git a/internal/game/render_cash_hunt.go b/internal/game/render_cash_hunt.go index 374d6c8..0bb05fc 100644 --- a/internal/game/render_cash_hunt.go +++ b/internal/game/render_cash_hunt.go @@ -8,6 +8,10 @@ import ( "thehouseoficarus/internal/color" ) +// cashHuntGradient is the board's accent gradient: red, orange, yellow, green, +// cyan, purple. It deliberately omits dark blue and ends on purple. +var cashHuntGradient = []int{196, 208, 220, 46, 51, 129} + func cashHuntPrizeColor(multiplier int) int { switch { case multiplier >= 10000: @@ -25,18 +29,25 @@ func cashHuntPrizeColor(multiplier int) int { case multiplier == 1: return 120 default: - return 240 + // 0x is not a prize: match the dim square numbers so it recedes. + return 237 } } const ( cashHuntColumns = 10 - cashHuntTileWidth = 7 + cashHuntTileWidth = 6 + // One outer border on each side plus one separator between each of the + // columns. + cashHuntBoardWidth = cashHuntColumns*(cashHuntTileWidth+1) + 1 ) -// cashHuntTileColors is a dark rainbow, one hue per column. The shades stay -// dark enough for bright prize text while replacing the old black/blue checker. -var cashHuntTileColors = [cashHuntColumns]int{88, 94, 100, 28, 29, 30, 24, 18, 90, 89} +// The whole board sits on black; the box-drawing grid supplies the separation. +// cashHuntBoxFg keeps the grid lines a touch darker than the square numbers. +const ( + cashHuntTileBlack = 16 + cashHuntBoxFg = 240 +) // cashHuntCenter pads text to a fixed tile width, keeping every column aligned // whether it holds a one- or three-digit square number. @@ -48,7 +59,38 @@ func cashHuntCenter(text string, width int) string { return strings.Repeat(" ", left) + text + strings.Repeat(" ", width-len(text)-left) } -func renderCashHunt(s *casino.CashHuntSnapshot, mode string, unicode bool) string { +// cashHuntHeader builds the board's single title row: difficulty (and the +// optional RTP readout) on the left, the gradient title centered, and the +// board's top multiplier on the right. Every segment carries the board's black +// background so the header reads as part of the same panel. +func cashHuntHeader(s *casino.CashHuntSnapshot, mode, rtp string, top int) string { + left := strings.ToUpper(s.Risk) + if rtp != "" { + left += " " + rtp + } + rightLabel := "TOP PRIZE: " + rightValue := fmt.Sprintf("%dx", top) + right := rightLabel + rightValue + mid := "CASH HUNT" + + inner := cashHuntBoardWidth - 2 + gap := inner - len(left) - len(mid) - len(right) + if gap < 1 { + gap = 1 + } + leftPad, rightPad := gap/2, gap-gap/2 + + left = color.Render(mode, color.ColorSpec{Fg: 250, Bg: cashHuntTileBlack}, left) + mid = color.Render(mode, color.ColorSpec{Gradient: cashHuntGradient, Bold: true, Bg: cashHuntTileBlack}, mid) + right = color.Render(mode, color.ColorSpec{Fg: 250, Bg: cashHuntTileBlack}, rightLabel) + + color.Render(mode, color.ColorSpec{Fg: cashHuntPrizeColor(top), Bold: true, Bg: cashHuntTileBlack}, rightValue) + space := func(n int) string { + return color.Render(mode, color.ColorSpec{Bg: cashHuntTileBlack}, strings.Repeat(" ", n)) + } + return left + space(leftPad) + mid + space(rightPad) + right +} + +func renderCashHunt(s *casino.CashHuntSnapshot, mode string, unicode bool, rtp string) string { paint := func(text string, fg int) string { return color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1}, text) } title := paint("CASH HUNT / "+strings.ToUpper(s.Risk)+" / 98% RTP", 220) switch s.Phase { @@ -57,14 +99,15 @@ func renderCashHunt(s *casino.CashHuntSnapshot, mode string, unicode bool) strin if unicode { side, topLeft, topRight, bottomLeft, bottomRight, bar = "│", "╭", "╮", "╰", "╯", "─" } - lines := []string{topLeft + strings.Repeat(bar, 68) + topRight} + inner := cashHuntBoardWidth - 2 + lines := []string{topLeft + strings.Repeat(bar, inner) + topRight} for _, text := range []string{"* THE VAULT WHIRLS ... THE PRIZES VANISH! *", "All 100 positions have been hidden and mixed.", "The prizes are sealed. Your choice unlocks one.", "Choose your square: type 1-100, or pick N."} { - padding := 68 - len(text) + padding := inner - len(text) lines = append(lines, side+strings.Repeat(" ", padding/2)+text+strings.Repeat(" ", padding-padding/2)+side) } - lines = append(lines, bottomLeft+strings.Repeat(bar, 68)+bottomRight) + lines = append(lines, bottomLeft+strings.Repeat(bar, inner)+bottomRight) for i, line := range lines { - lines[i] = color.Render(mode, color.ColorSpec{Fg: -1, Bg: -1, Gradient: []int{196, 208, 220, 46, 51, 45, 39, 21, 201, 207}}, line) + lines[i] = color.Render(mode, color.ColorSpec{Fg: -1, Bg: -1, Gradient: cashHuntGradient}, line) } return strings.Join(lines, "\n") case "paytable": @@ -74,35 +117,83 @@ func renderCashHunt(s *casino.CashHuntSnapshot, mode string, unicode bool) strin } return strings.Join(append(lines, "Returns include stake. Each board's mix varies; jackpots are not guaranteed."), "\n") case "preview", "result": - // Ten columns, two lines per tile: all 100 labels and even 50000x fit - // into a 70-column canvas without relying on cursor controls. - lines := []string{title} + // The subtitle carries the run state; the boxed header and grid below it + // form the board itself. + lines := []string{} + top := 0 + for _, value := range s.Squares { + top = max(top, value) + } if s.Phase == "preview" { - highest := 0 - for _, value := range s.Squares { - highest = max(highest, value) - } - lines = append(lines, paint(fmt.Sprintf("PRIZE PREVIEW / top prize %dx / positions will be mixed", highest), cashHuntPrizeColor(highest))) + lines = append(lines, paint("PRIZE PREVIEW / positions will be mixed", cashHuntPrizeColor(top))) } else { lines = append(lines, paint(fmt.Sprintf("YOUR PICK: %d / %dx", s.Selected, s.Squares[s.Selected-1]), 220)) } - for start := 0; start < casino.CashHuntSquares; start += cashHuntColumns { - var numbers, prizes strings.Builder - for k := start; k < start+cashHuntColumns; k++ { - bg := cashHuntTileColors[k%cashHuntColumns] + + tl, tr := "┌", "┐" + tm := "┬" + ml, mm, mr := "├", "┼", "┤" + bl, bm, br := "└", "┴", "┘" + hz, vt := "─", "│" + if !unicode { + tl, tr = "+", "+" + tm = "+" + ml, mm, mr = "+", "+", "+" + bl, bm, br = "+", "+", "+" + hz, vt = "-", "|" + } + border := func(left, mid, right string) string { + var b strings.Builder + b.WriteString(left) + for col := 0; col < cashHuntColumns; col++ { + b.WriteString(strings.Repeat(hz, cashHuntTileWidth)) + if col < cashHuntColumns-1 { + b.WriteString(mid) + } + } + b.WriteString(right) + return b.String() + } + // Every grid character carries the board's black background, and a + // foreground a shade darker than the numbers, so the panel reads as one + // piece. + box := func(s string) string { + return color.Render(mode, color.ColorSpec{Fg: cashHuntBoxFg, Bg: cashHuntTileBlack}, s) + } + + rows := casino.CashHuntSquares / cashHuntColumns + lines = append(lines, + box(tl+strings.Repeat(hz, cashHuntBoardWidth-2)+tr), + box(vt)+cashHuntHeader(s, mode, rtp, top)+box(vt), + // The header above is one merged cell, so the grid's top border uses + // down-T junctions (no vertical line rises into the header). + box(border(ml, tm, mr)), + ) + for row := 0; row < rows; row++ { + numbers := make([]string, cashHuntColumns) + prizes := make([]string, cashHuntColumns) + for col := 0; col < cashHuntColumns; col++ { + k := row*cashHuntColumns + col + bg := cashHuntTileBlack number := cashHuntCenter(fmt.Sprintf("%d", k+1), cashHuntTileWidth) value := cashHuntCenter(fmt.Sprintf("%dx", s.Squares[k]), cashHuntTileWidth) - nf, vf := 246, cashHuntPrizeColor(s.Squares[k]) + nf, vf := 244, cashHuntPrizeColor(s.Squares[k]) selected := s.Selected == k+1 if selected { number = cashHuntCenter(fmt.Sprintf(">%d<", k+1), cashHuntTileWidth) nf, vf, bg = 16, 16, 226 } - numbers.WriteString(color.Render(mode, color.ColorSpec{Fg: nf, Bg: bg, Bold: selected}, number)) - prizes.WriteString(color.Render(mode, color.ColorSpec{Fg: vf, Bg: bg, Bold: selected}, value)) + numbers[col] = color.Render(mode, color.ColorSpec{Fg: nf, Bg: bg, Bold: selected}, number) + // Zero is not a prize: leave 0x unbolded so only real multipliers pop. + prizes[col] = color.Render(mode, color.ColorSpec{Fg: vf, Bg: bg, Bold: s.Squares[k] > 0}, value) + } + lines = append(lines, box(vt)+strings.Join(numbers, box(vt))+box(vt)) + lines = append(lines, box(vt)+strings.Join(prizes, box(vt))+box(vt)) + if row < rows-1 { + lines = append(lines, box(border(ml, mm, mr))) } - lines = append(lines, numbers.String(), prizes.String()) } + lines = append(lines, box(border(bl, bm, br))) return strings.Join(lines, "\n") default: return title + "\n" + paint("100 squares. One choice. Prizes from 0x to 50000x.", 81) -- cgit v1.2.3