package game import ( "fmt" "strings" "testing" "unicode/utf8" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/color" ) func TestCashHuntCommandsAndReceipt(t *testing.T) { for _, choice := range []string{"100", "pick"} { t.Run(choice, func(t *testing.T) { g, s, conn := casinoCrapsGame(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"} g.executePlay(s, []string{"cash", "hunt"}, "play cash hunt") table, ok := g.Casino.Participation(s.Player.Name) if !ok || table == nil || table.Config.Game != casino.GameCashHunt { t.Fatal("Cash Hunt alias did not seat player") } g.executeCommand(s, "difficulty", []string{"hard"}, "difficulty hard") conn.data = nil 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, cashHuntCenter("100")) || len(s.Player.PendingWagers) != 1 { t.Fatalf("preview/shuffle sequence missing: %s", text) } if table.Busy(s.Player.Name) { t.Fatal("choosing must not suppress the input prompt") } conn.data = nil g.doLookTarget(s, "cash hunt") if strings.Contains(string(conn.data), "PRIZE PREVIEW") || !strings.Contains(string(conn.data), "hidden and mixed") { t.Fatal("look leaked sealed positions") } conn.data = nil if choice == "pick" { g.executeCommand(s, "pick", []string{"100"}, "pick 100") } else { // Exercise the real input path: a bare square number must survive // classification instead of being rejected as an unknown verb. g.handleGameCommand(s, choice) } text = string(conn.data) if strings.Contains(text, "Unknown command") { t.Fatalf("bare square number rejected: %s", text) } if len(s.Player.PendingWagers) != 0 || strings.Count(text, "Returned:") != 1 || !strings.Contains(text, ">100<") { t.Fatalf("selection/receipt failed: %s", text) } conn.data = nil g.executeCommand(s, "100", nil, "100") if strings.Contains(string(conn.data), "Returned:") { t.Fatal("duplicate selection paid again") } g.executeCommand(s, "paytable", nil, "paytable") if !strings.Contains(string(conn.data), "50000x") { t.Fatal("paytable missing jackpots") } }) } } func TestCashHuntBareNumberReachesGame(t *testing.T) { // A player typing any square number, including 1 and 100, must select it. g, s, conn := casinoCrapsGame(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"} g.executePlay(s, []string{"cash", "hunt"}, "play cash hunt") for _, square := range []string{"1", "9", "10", "99", "100"} { g.executeBet(s, []string{"1"}, "bet 1") conn.data = nil g.handleGameCommand(s, square) if !strings.Contains(string(conn.data), fmt.Sprintf(">%s<", square)) { t.Fatalf("square %s was not selected: %s", square, conn.data) } } } func TestCashHuntSettleReachesGame(t *testing.T) { // settle is not a shared-registry verb; a seated player must reach the game. g, s, conn := casinoCrapsGame(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"} g.executePlay(s, []string{"cash", "hunt"}, "play cash hunt") conn.data = nil g.handleGameCommand(s, "settle") if strings.Contains(string(conn.data), "Unknown command") { t.Fatalf("settle was rejected before reaching Cash Hunt: %s", conn.data) } } func TestCashHuntPlayAliasesWithWager(t *testing.T) { for _, name := range []string{"cash hunt", "cash_hunt", "cashhunt", "cash-hunt"} { g, s, conn := casinoCrapsGame(t, "name: Vault\ncasino_tables:\n - id: cash_hunt\n game: cash_hunt\n") args := append(strings.Fields(name), "1") g.executePlay(s, args, "play "+name+" 1") if len(s.Player.PendingWagers) != 1 || !strings.Contains(stripANSI(string(conn.data)), "PRIZE PREVIEW") { t.Fatalf("inline wager failed for %q: %s", name, conn.data) } } } func TestCashHuntBoardRendering(t *testing.T) { prizes := []int{0, 1, 2, 5, 10, 20, 100, 200, 500, 1000, 10000, 50000} for _, mode := range []string{"none", "ansi", "xterm256"} { for _, unicode := range []bool{false, true} { for _, phase := range []string{"preview", "result"} { s := &casino.CashHuntSnapshot{Phase: phase, Risk: "hard"} for i := range s.Squares { s.Squares[i] = prizes[i%len(prizes)] } if phase == "result" { s.Selected = 100 } 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") // 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[1:] { if utf8.RuneCountInString(line) != cashHuntBoardWidth { t.Fatalf("grid line is not %d columns: %q", cashHuntBoardWidth, line) } } 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++ { 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) } start := 1 + col*(cashHuntTileWidth+1) got := string(numberRunes[start : start+cashHuntTileWidth]) if want := cashHuntCenter(label); got != want { t.Fatalf("square %d label: got %q want %q", square, got, want) } } } if !strings.Contains(board, "50000x") { t.Fatal("largest prize clipped") } if !unicode { for _, c := range stripANSI(rendered) { if c > 127 { t.Fatal("ASCII mode printed Unicode") } } } if mode == "xterm256" { // 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") } 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")) { 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, "") for _, line := range strings.Split(stripANSI(hidden), "\n") { 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) } }