diff options
| author | historia <[not public]> | 2026-10-04 17:44:27 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-04 17:44:27 -0400 |
| commit | 93dcb67ffee05ebf1de0c9a78a8b906eaf161007 (patch) | |
| tree | 88cdd3d732f11eee616698a9ca32f699d2b41d77 /internal/game | |
| parent | 3a39a020e4449bc4d6f728ef837cffb9f7bd0c56 (diff) | |
| download | thehouseoficarus-93dcb67ffee05ebf1de0c9a78a8b906eaf161007.tar.gz | |
feat: new casino game: cash hunt, fixed verb issue with pai gow
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/cash_hunt_test.go | 178 | ||||
| -rw-r--r-- | internal/game/casino.go | 17 | ||||
| -rw-r--r-- | internal/game/casino_ux.go | 2 | ||||
| -rw-r--r-- | internal/game/cmd_registry.go | 47 | ||||
| -rw-r--r-- | internal/game/game.go | 10 | ||||
| -rw-r--r-- | internal/game/look_casino.go | 6 | ||||
| -rw-r--r-- | internal/game/look_target.go | 3 | ||||
| -rw-r--r-- | internal/game/pai_gow_test.go | 20 | ||||
| -rw-r--r-- | internal/game/plinko_test.go | 130 | ||||
| -rw-r--r-- | internal/game/render_cash_hunt.go | 110 | ||||
| -rw-r--r-- | internal/game/render_plinko.go | 103 |
11 files changed, 530 insertions, 96 deletions
diff --git a/internal/game/cash_hunt_test.go b/internal/game/cash_hunt_test.go new file mode 100644 index 0000000..65ef16f --- /dev/null +++ b/internal/game/cash_hunt_test.go @@ -0,0 +1,178 @@ +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, " 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) + 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)) + } + for _, line := range lines[2:] { + if utf8.RuneCountInString(line) != 70 { + t.Fatalf("tile row is not 70 columns: %q", line) + } + } + board := strings.Join(lines[2:], "\n") + if strings.ContainsAny(board, "[]") { + t.Fatalf("square numbers must not use brackets: %q", board) + } + for row := 0; row < 10; row++ { + numberLine := lines[2+2*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] + if want := cashHuntCenter(label, cashHuntTileWidth); 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" { + // 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") + } + for _, shade := range cashHuntTileColors { + if !strings.Contains(rendered, color.BgCode("xterm256", shade)) { + t.Fatalf("missing dark rainbow shade %d", shade) + } + } + } + } + hidden := renderCashHunt(&casino.CashHuntSnapshot{Phase: "hidden"}, mode, unicode) + for _, line := range strings.Split(stripANSI(hidden), "\n") { + if utf8.RuneCountInString(line) != 70 { + t.Fatalf("shuffle banner width changed: %q", line) + } + } + } + } +} diff --git a/internal/game/casino.go b/internal/game/casino.go index 15e3b6a..710090a 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -333,6 +333,16 @@ func (g *Game) executePlay(sess *net.Session, args []string, rawInput string) { input := "" if len(args) > 0 { input = args[0] + // Multi-word names (Cash Hunt, Pai Gow, Big Six) precede any + // optional wager. Resolve the longest matching name first. + for n := len(args); n > 1; n-- { + name := strings.Join(args[:n], " ") + if _, ok := resolveCasinoChoice(choices, name); ok { + input = name + args = append([]string{name}, args[n:]...) + break + } + } } choice, ok := resolveCasinoChoice(choices, input) if !ok { @@ -708,14 +718,15 @@ func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *cas _, isCraps := snapshot.(*casino.CrapsSnapshot) _, isPoker := snapshot.(*casino.VideoPokerSnapshot) _, isPlinko := snapshot.(*casino.PlinkoSnapshot) + _, isCashHunt := snapshot.(*casino.CashHuntSnapshot) _, isPaiGow := snapshot.(*casino.PaiGowSnapshot) b, isBigSix := snapshot.(*casino.BigSixSnapshot) isBigSix = isBigSix && b.Phase != "playing" && b.Phase != "result" s, isRoulette := snapshot.(*casino.RouletteSnapshot) - if !isPlinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && (!isRoulette || s.Phase == "playing" || s.Phase == "result") { + if !isCashHunt && !isPlinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && (!isRoulette || s.Phase == "playing" || s.Phase == "result") { return "", false } - if !isPlinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) { + if !isCashHunt && !isPlinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) { // The compact reminder is already a short block; leave it intact. return "", false } @@ -801,6 +812,8 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot) // how verbose the command menus are. size := sess.Player.OptionInt("card_size") switch s := snapshot.(type) { + case *casino.CashHuntSnapshot: + return renderCashHunt(s, mode, unicode) case *casino.PlinkoSnapshot: return renderPlinko(s, mode, unicode) case *casino.PaiGowSnapshot: diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go index bc1906f..e2b74db 100644 --- a/internal/game/casino_ux.go +++ b/internal/game/casino_ux.go @@ -66,6 +66,8 @@ func resolveCasinoChoice(choices []casinoChoice, input string) (casinoChoice, bo func casinoTableAliases(cfg casino.TableConfig) []string { names := []string{string(cfg.Game)} switch cfg.Game { + case casino.GameCashHunt: + names = append(names, "cash hunt", "cashhunt", "cash-hunt") case casino.GameBigSix: names = append(names, "big-six", "big_six", "big six", "big 6") case casino.GameBlackjack: diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index d8cc18f..4d48509 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -224,6 +224,40 @@ var commandRegistry = map[string]commandDef{ "hideallexits": {(*Game).executeHideAllExits, ClassInstant}, } +// isAllDigits reports whether every rune is an ASCII digit. +func isAllDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// casinoOwnedInput reports whether the seated game's interceptors in +// executeCommand own this command. Bare square/card numbers and the retry +// verbs (settle, set, house, autoset) are not all in the shared registry, so +// without this they would be rejected as unknown before reaching the game. +func (g *Game) casinoOwnedInput(sess *net.Session, cmd string) bool { + if sess == nil || sess.Player == nil || g.Casino == nil { + return false + } + table, playing := g.Casino.Participation(sess.Player.Name) + if !playing || table == nil { + return false + } + switch table.Config.Game { + case casino.GameCashHunt: + return isAllDigits(cmd) || cmd == "settle" + case casino.GamePaiGow: + return isAllDigits(cmd) || cmd == "set" || cmd == "house" || cmd == "autoset" || cmd == "settle" + } + return false +} + func classifyCommand(cmd string) CommandClass { if _, ok := casino.ParsePokerDigits(cmd); ok { return ClassInstant @@ -255,6 +289,7 @@ var casinoCommonVerbs = []string{"watch", "game", "table", "list", "stop", "rule // command: "sp" is split at a blackjack table, but spin at a Big 6 wheel, // because "split" is not a Big 6 verb and "spin" is not a blackjack verb. var casinoVerbsByGame = map[casino.GameName][]string{ + casino.GameCashHunt: {"bet", "rebet", "difficulty", "paytable", "pick", "settle"}, casino.GamePlinko: {"bet", "rebet", "difficulty", "balls", "paytable"}, casino.GameBlackjack: {"bet", "rebet", "unbet", "hit", "stand", "double", "split", "insurance", "surrender", "decline"}, casino.GameBaccarat: {"bet", "rebet", "unbet"}, @@ -363,6 +398,18 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI } } + // Cash Hunt owns numeric choices only while seated; other games and + // world commands keep their usual behavior. + if p != nil && g.Casino != nil { + if t, playing := g.Casino.Participation(p.Name); playing && t != nil && t.Config.Game == casino.GameCashHunt { + numeric := cmd != "" && cmd[0] >= '0' && cmd[0] <= '9' + if numeric || cmd == "pick" || cmd == "settle" { + g.executeTableAction(sess, strings.Join(append([]string{cmd}, args...), " ")) + return + } + } + } + // Pai Gow owns these inputs only while seated; leave normal commands alone. if p != nil && g.Casino != nil { if t, playing := g.Casino.Participation(p.Name); playing && t != nil && t.Config.Game == casino.GamePaiGow { diff --git a/internal/game/game.go b/internal/game/game.go index 32e3c24..521c892 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -647,6 +647,16 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { } } + // A seated game may own inputs the shared registry does not know: bare + // square/card numbers and retry verbs (settle, set, house, autoset). Route + // them straight to executeCommand, whose per-game interceptors handle them, + // instead of rejecting them as unknown verbs. + if g.casinoOwnedInput(sess, cmd) { + g.executeCommand(sess, cmd, parts[1:], input) + g.writePrompt(sess) + return + } + if len(parts) == 1 { switch cmd { case "equip", "eq", "equipment", "wear", "wield": diff --git a/internal/game/look_casino.go b/internal/game/look_casino.go index df6a714..32eb360 100644 --- a/internal/game/look_casino.go +++ b/internal/game/look_casino.go @@ -83,7 +83,7 @@ func (g *Game) renderCasinoTableDetails(sess *net.Session, cfg casino.TableConfi fmt.Sprintf(" Seated %s", seated), } switch cfg.Game { - case casino.GameRoulette, casino.GameBigSix, casino.GamePlinko: + case casino.GameRoulette, casino.GameBigSix, casino.GamePlinko, casino.GameCashHunt: // Look, like sitting down and the table command, shows the live // wheel: tracker, racetrack, and betting layout. if rendered := strings.TrimRight(g.renderCasinoSnapshot(sess, table.Board(sess.Player.Name)), "\n"); rendered != "" { @@ -103,8 +103,10 @@ func (g *Game) renderCasinoTableDetails(sess *net.Session, cfg casino.TableConfi // look details block. The full wording lives in the rules help page. func casinoTableSummary(cfg casino.TableConfig) string { switch cfg.Game { + case casino.GameCashHunt: + return "200 squares; three difficulties; 98% RTP; prizes up to 50000x" case casino.GamePlinko: - return fmt.Sprintf("16 rows; four risks; 1-10 balls; %.4g%% base RTP + upward rounding", cfg.Plinko.Normalize().RTP) + return fmt.Sprintf("16 rows; four risks; 1-10 balls; near %.4g%% target RTP", cfg.Plinko.Normalize().RTP) case casino.GameBlackjack: b := cfg.Blackjack.Normalize() dealer := "dealer stands on soft 17" diff --git a/internal/game/look_target.go b/internal/game/look_target.go index cfa6009..1c3d112 100644 --- a/internal/game/look_target.go +++ b/internal/game/look_target.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "thehouseoficarus/internal/casino" "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" @@ -47,7 +48,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { return } - if g.lookPlinkoTable(sess, lower) || g.lookBigSixTable(sess, lower) || g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) || g.lookCrapsTable(sess, lower) || g.lookPaiGowTable(sess, lower) || g.lookVideoPokerCabinet(sess, lower) { + if g.lookCasinoTable(sess, lower, casino.GameCashHunt) || g.lookPlinkoTable(sess, lower) || g.lookBigSixTable(sess, lower) || g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) || g.lookCrapsTable(sess, lower) || g.lookPaiGowTable(sess, lower) || g.lookVideoPokerCabinet(sess, lower) { return } diff --git a/internal/game/pai_gow_test.go b/internal/game/pai_gow_test.go index 830b080..35aee4e 100644 --- a/internal/game/pai_gow_test.go +++ b/internal/game/pai_gow_test.go @@ -416,3 +416,23 @@ func TestPaiGowStopMidRoundQueuesDeparture(t *testing.T) { t.Fatal("queued stop did not leave after the round") } } + +func TestPaiGowSeatedVerbsReachGame(t *testing.T) { + // house/set are not shared-registry verbs; a seated player must still be + // able to type them instead of getting "Unknown command". + g, sess, conn := casinoCrapsGame(t, "name: Pai Gow\ncasino_tables:\n - id: fortune\n game: paigow\n") + g.executePlay(sess, []string{"pai"}, "play pai") + g.executeBet(sess, []string{"5", "fortune"}, "bet 5 fortune") + g.executeBet(sess, []string{"20"}, "bet 20") + for _, verb := range []string{"house", "set"} { + conn.data = nil + g.handleGameCommand(sess, verb) + if strings.Contains(string(conn.data), "Unknown command") { + t.Fatalf("%s was rejected before reaching Pai Gow: %s", verb, conn.data) + } + } + table, _ := g.Casino.Participation(sess.Player.Name) + if table.Status(sess.Player.Name)[0].Snapshot.(*casino.PaiGowSnapshot).Phase != "waiting" { + t.Fatal("house/set did not finish the hand") + } +} diff --git a/internal/game/plinko_test.go b/internal/game/plinko_test.go index 711525f..dd3e205 100644 --- a/internal/game/plinko_test.go +++ b/internal/game/plinko_test.go @@ -24,6 +24,11 @@ func TestPlinkoCommands(t *testing.T) { t.Fatalf("commands failed: %+v", snap) } g.executeBet(s, []string{"1"}, "bet 1") + for _, removed := range []string{"Welcome to PLINKO", "*** PLINKO", "=== P L I N K O", "White = one ball", "Matching letters share", "(base "} { + if strings.Contains(string(conn.data), removed) { + t.Fatalf("obsolete prose still printed: %q", removed) + } + } if !table.Busy(s.Player.Name) || len(s.Player.PendingWagers) != 1 { t.Fatal("drop did not start") } @@ -35,12 +40,15 @@ func TestPlinkoCommands(t *testing.T) { t.Fatal("drop did not settle") } text := stripANSI(string(conn.data)) - if !strings.Contains(text, "Ball 10 ->") || strings.Count(text, "Returned:") != 1 || strings.Contains(text, "credits returned") || strings.Contains(text, "net +") { + if strings.Contains(text, "->") || strings.Count(text, "Returned:") != 1 || strings.Contains(text, "credits returned") || strings.Contains(text, "net +") { t.Fatal(text) } + if !strings.Contains(text, "Wager") || !strings.Contains(text, "Staked") { + t.Fatalf("settlement receipt missing: %s", text) + } conn.data = nil g.doLookTarget(s, "plinko") - if !strings.Contains(stripANSI(string(conn.data)), "P L I N K O") { + if !strings.Contains(stripANSI(string(conn.data)), "% RTP:") { t.Fatal("look missing paytable") } } @@ -75,16 +83,27 @@ func TestPlinkoRenderRows(t *testing.T) { } } wantLines := 1 - if row == plinkoRows/2 || row == plinkoRows { - wantLines = 2 // midpoint hint ruler, base rail + if row == plinkoRows { + wantLines = 2 // base rail } if got := len(strings.Split(out, "\n")); got != wantLines { t.Fatalf("row %d printed %d lines, want %d", row, got, wantLines) } - if strings.Contains(out, "\x1b[48;") { - t.Fatal("the board must not paint a background") + switch mode { + case "none": + if strings.Contains(out, "\x1b") { + t.Fatal("plain board must not contain escapes") + } + case "ansi", "xterm256": + // The board is background-free: the player's terminal theme + // shows through instead of a fixed gradient. + for bg := 0; bg < 256; bg++ { + if strings.Contains(out, color.BgCode(mode, bg)) { + t.Fatalf("%s board must not paint a background", mode) + } + } } - if mode == "none" && (!strings.Contains(out, "*") || strings.Contains(out, "\x1b")) { + if mode == "none" && !strings.Contains(out, "*") { t.Fatal("plain overlap fallback missing") } } @@ -102,7 +121,7 @@ func TestPlinkoUsesSharedHints(t *testing.T) { if strings.Contains(text, "difficulty easy|medium|hard|expert") != (mode != "off") { t.Fatalf("join hints (%s): %s", mode, text) } - if mode != "off" && strings.Index(text, "P L I N K O") > strings.Index(text, "bet N") { + if mode != "off" && strings.Index(text, "% RTP:") > strings.Index(text, "bet N") { t.Fatal("hints must follow the paytable") } conn.data = nil @@ -134,15 +153,17 @@ func plinkoTestBoardRow(s *casino.PlinkoSnapshot) []rune { } func TestPlinkoPegsNeverMove(t *testing.T) { + pockets := [17]float64{16, 7.8, 5.9, 4.4, 3.1, 2, 1.1, 0.5, 0.2, 0.5, 1.1, 2, 3.1, 4.4, 5.9, 7.8, 16} for row := 0; row <= 16; row++ { - s := &casino.PlinkoSnapshot{Row: row, Total: 1} + s := &casino.PlinkoSnapshot{Row: row, Total: 1, Pockets: pockets} empty := plinkoTestBoardRow(s) left, right := 32-2*row, 36+2*row pegs := map[int]bool{} for k := 1; k <= row; k++ { - pegs[left+4*k] = true - if empty[left+4*k] != '·' { - t.Fatalf("row %d: peg missing at its true position %d", row, left+4*k) + x := left + 4*k + pegs[x] = true + if empty[x] != '·' { + t.Fatalf("row %d: peg missing at its true position %d", row, x) } } if right < plinkoWidth && empty[right] != '\\' || empty[left] != '/' { @@ -165,35 +186,28 @@ func TestPlinkoPegsNeverMove(t *testing.T) { } } -func TestPlinkoHintsPrintOnceAtMidpoint(t *testing.T) { +func TestPlinkoHintsOverlayReminderRow(t *testing.T) { pockets := [17]float64{16, 7.8, 5.9, 4.4, 3.1, 2, 1.1, 0.5, 0.2, 0.5, 1.1, 2, 3.1, 4.4, 5.9, 7.8, 16} labels := casino.PlinkoPocketLabels(pockets) - isRuler := func(line string) bool { - r := []rune(line) - if len(r) != plinkoWidth || r[1] != ' ' { - return false - } - for k, label := range labels { - if r[2+4*k] != label { - return false - } - } - return true - } for row := 0; row <= 16; row++ { s := &casino.PlinkoSnapshot{Row: row, Total: 1, Pockets: pockets} - hasRuler := false - for _, line := range strings.Split(stripANSI(renderPlinko(s, "none", true)), "\n") { - hasRuler = hasRuler || isRuler(line) + lines := strings.Split(stripANSI(renderPlinko(s, "none", true)), "\n") + wantLines := 1 + if row == plinkoRows { + wantLines = 2 } - if hasRuler != (row == plinkoRows/2) { - t.Fatalf("row %d: hint ruler present=%v", row, hasRuler) + if len(lines) != wantLines { + t.Fatalf("row %d printed %d lines, want %d", row, len(lines), wantLines) } - // The pyramid row itself never carries pocket letters any more. - board := plinkoTestBoardRow(s) + board := []rune(lines[0]) for k, label := range labels { - if board[2+4*k] == label { - t.Fatalf("row %d: pocket hint leaked into the board", row) + x := 2 + 4*k + if row == plinkoHintRow { + if board[x] != label { + t.Fatalf("row %d: reminder %c missing at pocket column %d (got %q)", row, label, x, board[x]) + } + } else if board[x] == label { + t.Fatalf("row %d: reminder %c leaked into the board at %d", row, label, x) } } } @@ -225,11 +239,11 @@ func TestPlinkoLegendDownTheLeft(t *testing.T) { if left := 32 - 2*s.Row; left <= 10 { t.Fatalf("legend would collide with the pyramid: left border %d", left) } - // The paytable prints the same entries, one per line, not a wide block. + // The standalone paytable prints the same entries on one line. s.Row = 0 paytable := stripANSI(renderPlinko(&casino.PlinkoSnapshot{Pockets: s.Pockets, Risk: "easy", Paytable: true}, "none", true)) for _, e := range entries { - if strings.Count(paytable, fmt.Sprintf("%c %s", e.label, e.text)) != 1 { + if strings.Count(paytable, fmt.Sprintf("%c%s", e.label, e.text)) != 1 { t.Fatalf("paytable legend missing or duplicated %c", e.label) } } @@ -246,16 +260,42 @@ func TestPlinkoLegendDownTheLeft(t *testing.T) { } } -func TestPlinkoHintsAreDimWhite(t *testing.T) { - r, g, b := color.Xterm256ToRGB(plinkoHintColor()) - if r != g || g != b { - t.Fatalf("hint is not white: %d,%d,%d", r, g, b) +func TestPlinkoPaytableIsCompact(t *testing.T) { + s := &casino.PlinkoSnapshot{ + Paytable: true, Risk: "easy", Total: 1, RTP: 97.9998779296875, + Pockets: [17]float64{16, 7.8, 5.8, 4.3, 3, 2, 1.1, 0.4, 0.2, 0.4, 1.1, 2, 3, 4.3, 5.8, 7.8, 16}, } - if r > 95 { - t.Fatalf("hint is too bright: %d", r) + want := "easy 1 ball 98.00% RTP: A16x B7.8x C5.8x D4.3x E3x F2x G1.1x H0.4x I0.2x" + for _, mode := range []string{"none", "ansi", "xterm256"} { + out := renderPlinko(s, mode, true) + if plain := stripANSI(out); plain != want { + t.Fatalf("%s paytable: %q", mode, plain) + } + if mode != "none" { + for bg := 0; bg < 256; bg++ { + if strings.Contains(out, color.BgCode(mode, bg)) { + t.Fatalf("%s paytable must not paint a background", mode) + } + } + } } - br, bg, bb := color.Xterm256ToRGB(plinkoBallColor(1, 1)) - if r >= br || g >= bg || b >= bb { - t.Fatal("hint must be dimmer than a ball") +} + +func TestPlinkoLandingPockets(t *testing.T) { + s := &casino.PlinkoSnapshot{Row: plinkoRows, Total: 3} + s.Pockets[0], s.Pockets[16] = 16, 16 + s.Counts[0], s.Counts[8] = 1, 2 + for _, mode := range []string{"none", "ansi", "xterm256"} { + rail := strings.Split(renderPlinko(s, mode, true), "\n")[1] + plain := []rune(stripANSI(rail)) + for k := range s.Counts { + left, right := '[', ']' + if s.Counts[k] > 0 { + left, right = '>', '<' + } + if plain[1+4*k] != left || plain[3+4*k] != right { + t.Fatalf("%s: incorrect landing marker at pocket %d: %s", mode, k, string(plain)) + } + } } } diff --git a/internal/game/render_cash_hunt.go b/internal/game/render_cash_hunt.go new file mode 100644 index 0000000..374d6c8 --- /dev/null +++ b/internal/game/render_cash_hunt.go @@ -0,0 +1,110 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" +) + +func cashHuntPrizeColor(multiplier int) int { + switch { + case multiplier >= 10000: + return 225 + case multiplier >= 500: + return 201 + case multiplier >= 100: + return 220 + case multiplier >= 20: + return 207 + case multiplier >= 5: + return 141 + case multiplier >= 2: + return 81 + case multiplier == 1: + return 120 + default: + return 240 + } +} + +const ( + cashHuntColumns = 10 + cashHuntTileWidth = 7 +) + +// 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} + +// cashHuntCenter pads text to a fixed tile width, keeping every column aligned +// whether it holds a one- or three-digit square number. +func cashHuntCenter(text string, width int) string { + if len(text) >= width { + return text + } + left := (width - len(text)) / 2 + return strings.Repeat(" ", left) + text + strings.Repeat(" ", width-len(text)-left) +} + +func renderCashHunt(s *casino.CashHuntSnapshot, mode string, unicode bool) 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 { + case "hidden": + side, topLeft, topRight, bottomLeft, bottomRight, bar := "|", "+", "+", "+", "+", "-" + if unicode { + side, topLeft, topRight, bottomLeft, bottomRight, bar = "│", "╭", "╮", "╰", "╯", "─" + } + lines := []string{topLeft + strings.Repeat(bar, 68) + 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) + lines = append(lines, side+strings.Repeat(" ", padding/2)+text+strings.Repeat(" ", padding-padding/2)+side) + } + lines = append(lines, bottomLeft+strings.Repeat(bar, 68)+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) + } + return strings.Join(lines, "\n") + case "paytable": + lines := []string{title, "Prize Chance per square"} + for _, prize := range s.Odds { + lines = append(lines, paint(fmt.Sprintf("%6dx %10.6f%%", prize.Multiplier, float64(prize.Weight)/1_000_000), cashHuntPrizeColor(prize.Multiplier))) + } + 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} + 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))) + } 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] + number := cashHuntCenter(fmt.Sprintf("%d", k+1), cashHuntTileWidth) + value := cashHuntCenter(fmt.Sprintf("%dx", s.Squares[k]), cashHuntTileWidth) + nf, vf := 246, 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)) + } + lines = append(lines, numbers.String(), prizes.String()) + } + return strings.Join(lines, "\n") + default: + return title + "\n" + paint("100 squares. One choice. Prizes from 0x to 50000x.", 81) + } +} diff --git a/internal/game/render_plinko.go b/internal/game/render_plinko.go index 17f533a..7988d8c 100644 --- a/internal/game/render_plinko.go +++ b/internal/game/render_plinko.go @@ -44,8 +44,12 @@ func plinkoColorAt(x int, scale float64) int { max(0, min(255, int(float64(rgb[2])*scale)))) } -// plinkoHintColor is the dim white used by the one-time midpoint pocket ruler. -func plinkoHintColor() int { return 238 } +// plinkoHintRow is where the pocket reminders are overlaid. It sits two rows +// below the midpoint so the letters clear the payout legend, which only runs +// down to the midpoint row. +const plinkoHintRow = plinkoRows/2 + 2 + +const plinkoHintColor = 238 // Foregrounds are deliberately faint so the balls stay the brightest thing // on the board. @@ -82,15 +86,24 @@ func (line *plinkoLine) text(x int, text string, fg int) { } func (line plinkoLine) render(mode string) string { var b strings.Builder - for _, cell := range line { - fg := cell.fg - // Basic ANSI has no dim white; the dim attribute plus bright black is - // its closest readable stand-in for a faint white hint. - dim := false - if cell.guide && mode == "ansi" { - fg, dim = 7, true + // Paint contiguous runs, not individual cells. This keeps the same fixed + // canvas without flooding a slow MUD connection with redundant resets. + for x := 0; x < len(line); { + cell := line[x] + end := x + 1 + for end < len(line) && line[end].fg == cell.fg && line[end].guide == cell.guide { + end++ + } + var text strings.Builder + for _, c := range line[x:end] { + text.WriteRune(c.glyph) } - b.WriteString(color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Dim: dim}, string(cell.glyph))) + fg, dim := cell.fg, cell.guide && mode == "ansi" + if dim { + fg = 7 // Basic ANSI approximates the faint gray with dim white. + } + b.WriteString(color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Dim: dim}, text.String())) + x = end } return b.String() } @@ -125,43 +138,38 @@ func plinkoLegendText(e plinkoLegendEntry) string { return fmt.Sprintf("%c %-7s", e.label, e.text) } -// The legend is written down the left, outside the pyramid, one entry per -// row, so every value is known well before the ball reaches the pockets. -func renderPlinkoLegend(s *casino.PlinkoSnapshot, mode string) []string { - var lines []string +// The standalone paytable is compact text, not another board canvas. +func renderPlinkoPaytable(s *casino.PlinkoSnapshot, mode string) string { + balls := "balls" + if s.Total == 1 { + balls = "ball" + } + parts := []string{fmt.Sprintf("%s %d %s %.2f%% RTP:", s.Risk, s.Total, balls, s.RTP)} for _, e := range plinkoLegendEntries(s) { - line := newPlinkoLine() - line.text(1, plinkoLegendText(e), plinkoPocketColor(2+4*e.pocket)) - lines = append(lines, line.render(mode)) + parts = append(parts, color.Render(mode, color.ColorSpec{Fg: plinkoPocketColor(2 + 4*e.pocket), Bg: -1}, fmt.Sprintf("%c%s", e.label, e.text))) } - return lines + return strings.Join(parts, " ") } -// plinkoHintLine is the one-time pocket ruler printed at the board's midpoint, -// so players learn the pocket columns without a letter on every row. -func plinkoHintLine(labels [17]rune, mode string) string { - hint := newPlinkoLine() +// plinkoOverlayHints prints the pocket reminders across the reminder row. The +// letters sit on the fixed pocket columns (2+4k) so they line up with the base +// rail. Hints only fill empty cells: a falling ball must never disappear. +func plinkoOverlayHints(line *plinkoLine, labels [17]rune) { for k, label := range labels { - hint[2+4*k] = plinkoCell{glyph: label, fg: plinkoHintColor(), guide: true} + x := 2 + 4*k + if line[x].glyph == ' ' { + line[x] = plinkoCell{glyph: label, fg: plinkoHintColor, guide: true} + } } - return hint.render(mode) } // The legend appears down the left, then each snapshot appends one scanline. -// The pocket hints print once, as a ruler at the board's midpoint. +// The pocket reminders are overlaid on their own board row, not a line of their +// own. func renderPlinko(s *casino.PlinkoSnapshot, mode string, unicode bool) string { labels := casino.PlinkoPocketLabels(s.Pockets) if s.Paytable { - title := "=== P L I N K O / " + strings.ToUpper(s.Risk) + " ===" - heading := newPlinkoLine() - heading.text((plinkoWidth-len(title))/2, title, plinkoPocketColor(34)) - note := newPlinkoLine() - note.text(1, "Matching letters share a payout; returns include the stake.", plinkoPocketColor(34)) - lines := []string{heading.render(mode), note.render(mode)} - lines = append(lines, renderPlinkoLegend(s, mode)...) - tail := newPlinkoLine() - tail.text(1, "White = one ball; shades to red = all balls overlapping.", plinkoPocketColor(34)) - return strings.Join(append(lines, tail.render(mode)), "\n") + return renderPlinkoPaytable(s, mode) } ball, peg := 'o', '.' if unicode { @@ -169,10 +177,7 @@ func renderPlinko(s *casino.PlinkoSnapshot, mode string, unicode bool) string { } line := newPlinkoLine() left, right := 32-2*s.Row, 36+2*s.Row - line[left] = plinkoCell{glyph: '/', fg: plinkoBorderColor(left)} - line[right] = plinkoCell{glyph: '\\', fg: plinkoBorderColor(right)} - // Pegs sit at their true between-lane positions; they are never moved to - // make room for a guide letter. + // Pegs sit at their true between-lane positions. for k := 1; k <= s.Row; k++ { x := left + 4*k line[x] = plinkoCell{glyph: peg, fg: plinkoPegColor(x)} @@ -191,21 +196,27 @@ func renderPlinko(s *casino.PlinkoSnapshot, mode string, unicode bool) string { } line[left+2+4*k] = plinkoCell{glyph: glyph, fg: plinkoBallColor(count, s.Total)} } + line[left] = plinkoCell{glyph: '/', fg: plinkoBorderColor(left)} + line[right] = plinkoCell{glyph: '\\', fg: plinkoBorderColor(right)} if entries := plinkoLegendEntries(s); s.Row < len(entries) { e := entries[s.Row] line.text(1, plinkoLegendText(e), plinkoPocketColor(2+4*e.pocket)) } - lines := []string{line.render(mode)} - if s.Row == plinkoRows/2 { - lines = append(lines, plinkoHintLine(labels, mode)) + if s.Row == plinkoHintRow { + plinkoOverlayHints(&line, labels) } - if s.Row == 16 { + lines := []string{line.render(mode)} + if s.Row == plinkoRows { rail := newPlinkoLine() for k, label := range labels { x := 1 + 4*k - rail[x] = plinkoCell{glyph: '[', fg: plinkoPegColor(x)} - rail[x+1] = plinkoCell{glyph: label, fg: plinkoPocketColor(x + 1)} - rail[x+2] = plinkoCell{glyph: ']', fg: plinkoPegColor(x + 2)} + left, right := '[', ']' + fg := plinkoPocketColor(x + 1) + if s.Counts[k] > 0 { + left, right = '>', '<' + fg = plinkoBallColor(s.Counts[k], s.Total) + } + rail.text(x, string([]rune{left, label, right}), fg) } lines = append(lines, rail.render(mode)) } |
