aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-04 22:54:38 -0400
committerhistoria <[not public]>2026-10-04 22:54:38 -0400
commit8b78aa4cd930fde145959704ba7bdc0e57465b9b (patch)
treee9f3b5fe691084514359fb420cb57f5e41791a3d
parentc5ee77c345b4cafe10cf29b6c17a69e78cc5c630 (diff)
downloadthehouseoficarus-8b78aa4cd930fde145959704ba7bdc0e57465b9b.tar.gz
feat: better ux in cash hunt
-rw-r--r--internal/casino/cash_hunt.go3
-rw-r--r--internal/game/cash_hunt_test.go114
-rw-r--r--internal/game/casino.go6
-rw-r--r--internal/game/render_cash_hunt.go143
4 files changed, 220 insertions, 46 deletions
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)