aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-04 17:44:27 -0400
committerhistoria <[not public]>2026-10-04 17:44:27 -0400
commit93dcb67ffee05ebf1de0c9a78a8b906eaf161007 (patch)
tree88cdd3d732f11eee616698a9ca32f699d2b41d77 /internal/game
parent3a39a020e4449bc4d6f728ef837cffb9f7bd0c56 (diff)
downloadthehouseoficarus-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.go178
-rw-r--r--internal/game/casino.go17
-rw-r--r--internal/game/casino_ux.go2
-rw-r--r--internal/game/cmd_registry.go47
-rw-r--r--internal/game/game.go10
-rw-r--r--internal/game/look_casino.go6
-rw-r--r--internal/game/look_target.go3
-rw-r--r--internal/game/pai_gow_test.go20
-rw-r--r--internal/game/plinko_test.go130
-rw-r--r--internal/game/render_cash_hunt.go110
-rw-r--r--internal/game/render_plinko.go103
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))
}