aboutsummaryrefslogtreecommitdiff
path: root/internal
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
parent3a39a020e4449bc4d6f728ef837cffb9f7bd0c56 (diff)
downloadthehouseoficarus-93dcb67ffee05ebf1de0c9a78a8b906eaf161007.tar.gz
feat: new casino game: cash hunt, fixed verb issue with pai gow
Diffstat (limited to 'internal')
-rw-r--r--internal/casino/cash_hunt.go342
-rw-r--r--internal/casino/cash_hunt_test.go265
-rw-r--r--internal/casino/core.go3
-rw-r--r--internal/casino/plinko.go173
-rw-r--r--internal/casino/plinko_test.go116
-rw-r--r--internal/casino/registry.go9
-rw-r--r--internal/casino/types.go1
-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
18 files changed, 1392 insertions, 143 deletions
diff --git a/internal/casino/cash_hunt.go b/internal/casino/cash_hunt.go
new file mode 100644
index 0000000..7c9752f
--- /dev/null
+++ b/internal/casino/cash_hunt.go
@@ -0,0 +1,342 @@
+package casino
+
+import (
+ "fmt"
+ "math/rand"
+ "strconv"
+ "strings"
+ "sync"
+)
+
+const CashHuntSquares = 100
+const cashHuntWeightTotal = 100_000_000
+const cashHuntMaxMultiplier = 50_000
+
+var cashHuntRisks = [...]string{"easy", "medium", "hard"}
+var cashHuntMultipliers = [...]int{0, 1, 2, 5, 10, 20, 100, 200, 500, 1000, 10000, 50000}
+
+// Each row sums to 100 million and its multiplier-weighted sum is exactly
+// 98 million. All risks return 98%; only variance changes. Integer tickets
+// make even the rarest prizes reachable without floating-point thresholds.
+var cashHuntWeights = [3][12]int{
+ {50846940, 30000000, 15000000, 3000000, 800000, 300000, 40000, 10000, 2000, 1000, 50, 10},
+ {71345640, 15000000, 10000000, 2000000, 1000000, 500000, 100000, 40000, 10000, 4000, 300, 60},
+ {88068640, 5000000, 5000000, 1000000, 500000, 250000, 100000, 50000, 20000, 10000, 1000, 360},
+}
+
+func cashHuntPrize(risk, ticket int) int {
+ for i, weight := range cashHuntWeights[risk] {
+ if ticket < weight {
+ return cashHuntMultipliers[i]
+ }
+ ticket -= weight
+ }
+ panic("invalid Cash Hunt ticket")
+}
+
+func validateCashHunt(c TableConfig) error {
+ if c.MaxPlayers > 1 {
+ return fmt.Errorf("Cash Hunt has one seat")
+ }
+ if c.MaxBet > MaxCredits/cashHuntMaxMultiplier {
+ return fmt.Errorf("Cash Hunt max_bet must not exceed %d", MaxCredits/cashHuntMaxMultiplier)
+ }
+ return nil
+}
+
+func cashHuntRules(TableConfig) string {
+ return "Cash Hunt: 100 squares; easy / medium / hard; 98% RTP at every difficulty.\nBet first to generate and reveal the prizes. Their positions are then hidden and uniformly shuffled before you choose. Type a number from 1 to 100 (or pick N). No prizes are generated after your choice.\nMultipliers include your stake. 0x loses, 1x returns your stake. Rare prizes reach 50000x; large prizes are not guaranteed on any board.\nYou cannot cancel or change difficulty after betting. On disconnect, an unpicked board settles square 1. Shutdown refunds unpicked boards. Failed payouts remain pending; settle retries the same prize."
+}
+
+type CashHuntPrizeOdds struct{ Multiplier, Weight int }
+
+type CashHuntSnapshot struct {
+ Phase string // idle, preview, hidden, result, paytable
+ Risk string
+ Squares [CashHuntSquares]int // never populated while hidden
+ Selected int // one-based; zero means not chosen
+ Odds []CashHuntPrizeOdds
+}
+
+func (*CashHuntSnapshot) snapshot() {}
+
+type cashHuntTable struct {
+ *tableBase
+ mu sync.Mutex
+ rng *rand.Rand
+ name string
+ risk, betSize int
+ active bool
+ squares [CashHuntSquares]int
+ selected int
+ wallet Wallet
+ stake Wager
+}
+
+func newCashHuntTable(room int, c TableConfig, rng *rand.Rand) tableGame {
+ return &cashHuntTable{tableBase: &tableBase{RoomID: room, Config: c}, rng: rng}
+}
+func (t *cashHuntTable) base() *tableBase { return t.tableBase }
+func (t *cashHuntTable) idle() bool { t.mu.Lock(); defer t.mu.Unlock(); return t.name == "" }
+func (t *cashHuntTable) hasParticipant(n string) bool {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.name != "" && t.name == n
+}
+func (t *cashHuntTable) overview() string { return "Cash Hunt / 100 hidden prizes" }
+func (t *cashHuntTable) seats() []string {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if t.name == "" {
+ return nil
+ }
+ return []string{t.name}
+}
+func (t *cashHuntTable) state() string {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if t.active {
+ if t.selected != 0 {
+ return "payout pending"
+ }
+ return "choosing a square"
+ }
+ return "waiting"
+}
+func (t *cashHuntTable) wagers(string) []WagerRow { return nil }
+func (t *cashHuntTable) tick() []Event { return nil }
+func (t *cashHuntTable) hintsLocked() []MenuCommand {
+ if t.active {
+ if t.selected != 0 {
+ return []MenuCommand{{Command: "settle", Desc: "Retry the pending payout"}}
+ }
+ return []MenuCommand{{Command: "pick N", Desc: "Choose square 1-100; a bare number also works"}, {Command: "rules", Desc: "Cash Hunt rules"}}
+ }
+ return []MenuCommand{{Command: "bet N", Desc: "Reveal 100 prizes and shuffle their positions"}, {Command: "difficulty easy|medium|hard", Desc: "Choose variance; all return 98%"}, {Command: "paytable", Desc: "Prize probabilities"}, {Command: "rules", Desc: "Cash Hunt rules"}, {Command: "stop", Desc: "Leave Cash Hunt"}}
+}
+func (t *cashHuntTable) hints(n string) []MenuCommand {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if n != t.name {
+ return nil
+ }
+ return t.hintsLocked()
+}
+func (t *cashHuntTable) snapLocked(phase string) *CashHuntSnapshot {
+ s := &CashHuntSnapshot{Phase: phase, Risk: cashHuntRisks[t.risk], Selected: t.selected}
+ if phase == "preview" || phase == "result" {
+ s.Squares = t.squares
+ }
+ if phase == "paytable" {
+ for i, m := range cashHuntMultipliers {
+ s.Odds = append(s.Odds, CashHuntPrizeOdds{m, cashHuntWeights[t.risk][i]})
+ }
+ }
+ return s
+}
+func (t *cashHuntTable) phaseLocked() string {
+ if t.selected != 0 {
+ return "result"
+ }
+ if t.active {
+ return "hidden"
+ }
+ return "idle"
+}
+func (t *cashHuntTable) board(string) Snapshot {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.snapLocked(t.phaseLocked())
+}
+func (t *cashHuntTable) statusLocked(n string) []Event {
+ e := t.private(n, "")
+ e.Snapshot = t.snapLocked(t.phaseLocked())
+ e.Color = "casino_menu"
+ if n == t.name {
+ e.Menu = &MenuView{ShowBalance: true, Commands: t.hintsLocked()}
+ }
+ return []Event{e}
+}
+func (t *cashHuntTable) status(n string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.statusLocked(n)
+}
+func (t *cashHuntTable) join(n string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if t.name != "" {
+ return []Event{t.private(n, "Cash Hunt is occupied.")}
+ }
+ t.name, t.risk = n, 0
+ e := t.private(n, "")
+ e.Type, e.Player = EventJoined, n
+ return append([]Event{e}, t.statusLocked(n)...)
+}
+func (t *cashHuntTable) clearLocked() {
+ t.name, t.active, t.selected, t.betSize = "", false, 0, 0
+ t.wallet, t.stake, t.squares = nil, Wager{}, [CashHuntSquares]int{}
+}
+func (t *cashHuntTable) leave(n string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if n != t.name {
+ return nil
+ }
+ if t.active {
+ return []Event{t.private(n, "Finish your hunt before leaving.")}
+ }
+ t.clearLocked()
+ e := t.playerAction(n, n+" leaves Cash Hunt.", "You leave Cash Hunt.")
+ e.Type, e.Player = EventLeft, n
+ return []Event{e}
+}
+func (t *cashHuntTable) rebet(n string) (string, int) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if n == t.name {
+ return "", t.betSize
+ }
+ return "", 0
+}
+func (t *cashHuntTable) bet(n, spot string, amount int, w Wallet) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if n != t.name {
+ return nil
+ }
+ if t.active {
+ return []Event{t.private(n, "Your prizes are already sealed. Finish this hunt first.")}
+ }
+ if spot != "" && spot != "main" {
+ return []Event{t.private(n, "Use bet N, then choose a square.")}
+ }
+ if amount < t.Config.MinBet || amount > t.Config.MaxBet || amount > MaxCredits/cashHuntMaxMultiplier {
+ return []Event{t.private(n, "Bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")}
+ }
+ if w == nil {
+ return []Event{t.private(n, "No wallet is available.")}
+ }
+ stake, err := w.Wager(amount)
+ if err != nil {
+ return []Event{t.private(n, walletErrorMessage(err, "Not enough chips and credits."))}
+ }
+ t.wallet, t.stake, t.betSize, t.active, t.selected = w, stake, amount, true, 0
+ for i := range t.squares {
+ t.squares[i] = cashHuntPrize(t.risk, t.rng.Intn(cashHuntWeightTotal))
+ }
+ preview := t.private(n, fmt.Sprintf("Stake: %d credits. Here are your 100 prizes.", amount))
+ preview.Snapshot = t.snapLocked("preview") // value copy BEFORE the shuffle
+ t.rng.Shuffle(len(t.squares), func(i, j int) { t.squares[i], t.squares[j] = t.squares[j], t.squares[i] })
+ sealed := t.private(n, "")
+ sealed.Snapshot = t.snapLocked("hidden")
+ return []Event{preview, sealed}
+}
+func (t *cashHuntTable) action(n, action string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if n != t.name {
+ return nil
+ }
+ f := strings.Fields(strings.ToLower(action))
+ if len(f) == 1 && f[0] == "paytable" {
+ e := t.private(n, "")
+ e.Snapshot = t.snapLocked("paytable")
+ return []Event{e}
+ }
+ if len(f) == 2 && f[0] == "difficulty" {
+ if t.active {
+ return []Event{t.private(n, "Difficulty is locked until this hunt finishes.")}
+ }
+ for risk, name := range cashHuntRisks {
+ if f[1] == name {
+ t.risk = risk
+ t.selected = 0
+ return t.statusLocked(n)
+ }
+ }
+ return []Event{t.private(n, "Use difficulty easy|medium|hard.")}
+ }
+ if len(f) == 1 && f[0] == "settle" && t.active && t.selected != 0 {
+ return t.settleLocked()
+ }
+ if len(f) == 2 && f[0] == "pick" {
+ f = f[1:]
+ }
+ if len(f) != 1 {
+ return []Event{t.private(n, "Choose a square with pick N or a number from 1 to 100.")}
+ }
+ square, err := strconv.Atoi(f[0])
+ if err != nil || square < 1 || square > CashHuntSquares {
+ return []Event{t.private(n, "Choose a square from 1 to 100.")}
+ }
+ if !t.active {
+ return []Event{t.private(n, "Use bet N to start a new hunt.")}
+ }
+ if t.selected != 0 {
+ return []Event{t.private(n, "Your choice is locked. Use settle to retry its payout.")}
+ }
+ t.selected = square
+ return t.settleLocked()
+}
+
+// Selection and settlement deliberately contain no RNG calls. A failed bank
+// save leaves the SAME chosen square and stake locked for an idempotent retry.
+func (t *cashHuntTable) settleLocked() []Event {
+ multiplier := t.squares[t.selected-1]
+ paid := t.betSize * multiplier
+ reveal := t.private(t.name, fmt.Sprintf("Square %03d: %dx.", t.selected, multiplier))
+ reveal.Snapshot = t.snapLocked("result")
+ if err := t.wallet.PayCredits(paid, t.stake.ID); err != nil {
+ return []Event{reveal, t.private(t.name, payoutFailedMessage()+" Use settle to retry this same prize.")}
+ }
+ t.active, t.stake = false, Wager{}
+ outcome := "loss"
+ if multiplier == 1 {
+ outcome = "push"
+ } else if multiplier > 1 {
+ outcome = "win"
+ }
+ receipt := t.private(t.name, "")
+ receipt.Settlement = []SettlementLine{{Label: fmt.Sprintf("Cash Hunt #%03d", t.selected), Odds: "Varies", Stake: t.betSize, Returned: paid, Result: outcome}}
+ receipt.Color = "casino_payout"
+ if multiplier >= 100 {
+ receipt.Color = "casino_big_win"
+ }
+ return []Event{reveal, receipt, t.betMenuEvent(t.name, t.hintsLocked())}
+}
+func (t *cashHuntTable) markDisconnected(n string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if n != t.name {
+ return nil
+ }
+ var events []Event
+ if t.active {
+ if t.selected == 0 {
+ t.selected = 1
+ }
+ events = t.settleLocked()
+ }
+ t.clearLocked()
+ e := t.event(n+" leaves Cash Hunt.", true)
+ e.Type, e.Player = EventLeft, n
+ return append(events, e)
+}
+func (t *cashHuntTable) refundAll() []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ var events []Event
+ if t.active && t.selected != 0 {
+ events = t.settleLocked()
+ } else if t.active {
+ chips, credits, err := t.wallet.RefundOutstanding()
+ message := fmt.Sprintf("Cash Hunt closes; %d credits/chips refunded.", chips+credits)
+ if err != nil {
+ message = "Cash Hunt refund could not be persisted; your stake remains pending for recovery."
+ }
+ events = []Event{t.private(t.name, message)}
+ }
+ t.clearLocked()
+ return events
+}
diff --git a/internal/casino/cash_hunt_test.go b/internal/casino/cash_hunt_test.go
new file mode 100644
index 0000000..62cbb06
--- /dev/null
+++ b/internal/casino/cash_hunt_test.go
@@ -0,0 +1,265 @@
+package casino
+
+import (
+ "math"
+ "math/rand"
+ "sort"
+ "strconv"
+ "testing"
+)
+
+func newTestCashHunt() *cashHuntTable {
+ return newTable(1, TableConfig{ID: "cash_hunt", Game: GameCashHunt}, rand.New(rand.NewSource(42))).game.(*cashHuntTable)
+}
+
+type cashHuntNoRandom struct{}
+
+func (cashHuntNoRandom) Int63() int64 { panic("Cash Hunt used RNG after the board was sealed") }
+func (cashHuntNoRandom) Seed(int64) {}
+
+func TestCashHuntExactRTPAndVariance(t *testing.T) {
+ lastVariance := 0.0
+ for risk, weights := range cashHuntWeights {
+ total, returned, boundary := 0, int64(0), 0
+ second := 0.0
+ for i, weight := range weights {
+ multiplier := cashHuntMultipliers[i]
+ if weight <= 0 {
+ t.Fatal("every advertised prize must be reachable")
+ }
+ for _, ticket := range []int{boundary, boundary + weight - 1} {
+ if got := cashHuntPrize(risk, ticket); got != multiplier {
+ t.Fatalf("risk %d ticket %d: %dx, want %dx", risk, ticket, got, multiplier)
+ }
+ }
+ boundary += weight
+ total += weight
+ returned += int64(weight) * int64(multiplier)
+ second += float64(weight) * float64(multiplier) * float64(multiplier) / cashHuntWeightTotal
+ }
+ if total != cashHuntWeightTotal || returned != 98_000_000 {
+ t.Fatalf("risk %d: tickets %d, weighted return %d", risk, total, returned)
+ }
+ variance := second - .98*.98
+ if variance <= lastVariance {
+ t.Fatal("difficulty must increase variance")
+ }
+ lastVariance = variance
+ }
+ c := TableConfig{ID: "hunt", Game: GameCashHunt}
+ if got := c.HouseEdge("main"); len(got.Parts) != 1 || math.Abs(got.Parts[0].Loss-.02) > 1e-12 {
+ t.Fatalf("wrong reported edge: %+v", got)
+ }
+}
+
+func TestCashHuntEveryChoiceUsesSealedBoard(t *testing.T) {
+ for pick := 1; pick <= CashHuntSquares; pick++ {
+ table := newTestCashHunt()
+ table.join("alice")
+ wallet := &testWallet{credits: 100}
+ events := table.bet("alice", "", 10, wallet)
+ preview := events[0].Snapshot.(*CashHuntSnapshot)
+ hidden := events[1].Snapshot.(*CashHuntSnapshot)
+ if hidden.Phase != "hidden" || hidden.Squares != [CashHuntSquares]int{} {
+ t.Fatal("hidden snapshot leaked prizes")
+ }
+ if preview.Squares == table.squares {
+ t.Fatal("seeded board was not shuffled")
+ }
+ before, after := preview.Squares, table.squares
+ sort.Ints(before[:])
+ sort.Ints(after[:])
+ if before != after {
+ t.Fatal("shuffle changed prize inventory")
+ }
+ sealed := table.squares
+ // Neither look/status nor an invalid input may reveal or regenerate it.
+ table.rng = rand.New(cashHuntNoRandom{})
+ if table.board("spectator").(*CashHuntSnapshot).Squares != [CashHuntSquares]int{} {
+ t.Fatal("spectator can see hidden board")
+ }
+ table.status("alice")
+ for _, input := range []string{"0", "201", "-1", "pick 1 2", "difficulty hard", "nope"} {
+ table.action("alice", input)
+ }
+ table.bet("alice", "", 10, wallet)
+ table.leave("alice")
+ if !table.active || table.risk != 0 || table.squares != sealed || len(wallet.open) != 1 {
+ t.Fatal("locked board changed")
+ }
+ result := table.action("alice", strconv.Itoa(pick))
+ if wallet.paid != 10*sealed[pick-1] || len(wallet.open) != 0 || table.active {
+ t.Fatal("selected square was not paid exactly")
+ }
+ snap := result[0].Snapshot.(*CashHuntSnapshot)
+ if snap.Squares != sealed || snap.Selected != pick || preview.Squares == sealed {
+ t.Fatal("reveal or preview mutated")
+ }
+ if len(result[1].Settlement) != 1 {
+ t.Fatal("receipt missing")
+ }
+ paid := wallet.paid
+ table.action("alice", strconv.Itoa(pick))
+ table.action("alice", "settle")
+ table.tick()
+ table.refundAll()
+ if wallet.paid != paid {
+ t.Fatal("duplicate payout/refund")
+ }
+ }
+}
+
+func TestCashHuntAllPrizePayouts(t *testing.T) {
+ for _, multiplier := range cashHuntMultipliers {
+ table := newTestCashHunt()
+ table.join("alice")
+ wallet := &testWallet{credits: 100}
+ table.bet("alice", "", 10, wallet)
+ table.squares[99] = multiplier
+ table.rng = rand.New(cashHuntNoRandom{})
+ events := table.action("alice", "pick 100")
+ receipt := events[1].Settlement[0]
+ if wallet.paid != multiplier*10 || receipt.Returned != multiplier*10 || len(wallet.open) != 0 {
+ t.Fatalf("wrong payout for %dx", multiplier)
+ }
+ if receipt.Result != returnResult(10, multiplier*10) {
+ t.Fatal("wrong win/push/loss")
+ }
+ }
+}
+
+func TestCashHuntPayoutFailureLocksChoice(t *testing.T) {
+ table := newTestCashHunt()
+ table.join("alice")
+ wallet := &failingPayoutWallet{testWallet: testWallet{credits: 100}}
+ table.bet("alice", "", 10, wallet)
+ table.squares[6] = 50000
+ table.rng = rand.New(cashHuntNoRandom{})
+ events := table.action("alice", "7")
+ for _, e := range events {
+ if len(e.Settlement) != 0 {
+ t.Fatal("failed payment claimed a receipt")
+ }
+ }
+ table.action("alice", "8")
+ table.leave("alice")
+ table.bet("alice", "", 1, wallet)
+ if !table.active || table.selected != 7 || len(wallet.open) != 1 || wallet.paid != 0 {
+ t.Fatal("failed payout lost its chosen prize or stake")
+ }
+ table.wallet = &wallet.testWallet // persistence recovered, same pending stake
+ table.action("alice", "settle")
+ if table.active || wallet.paid != 500000 || len(wallet.open) != 0 {
+ t.Fatal("retry did not pay the original prize")
+ }
+ table.action("alice", "settle")
+ if wallet.paid != 500000 {
+ t.Fatal("retry paid twice")
+ }
+}
+
+func TestCashHuntDisconnectAndShutdown(t *testing.T) {
+ for _, disconnect := range []bool{false, true} {
+ table := newTestCashHunt()
+ table.join("alice")
+ wallet := &testWallet{chips: 3, credits: 100}
+ table.bet("alice", "", 10, wallet)
+ sealed := table.squares
+ table.rng = rand.New(cashHuntNoRandom{})
+ if disconnect {
+ table.markDisconnected("alice")
+ if wallet.paid != 10*sealed[0] {
+ t.Fatal("disconnect did not settle committed square 1")
+ }
+ } else {
+ table.refundAll()
+ if wallet.chips != 3 || wallet.credits+wallet.paid != 100 {
+ t.Fatal("shutdown failed to refund unpicked board")
+ }
+ }
+ if !table.idle() || len(wallet.open) != 0 {
+ t.Fatal("seat or stake leaked")
+ }
+ paid := wallet.paid
+ table.refundAll()
+ if wallet.paid != paid {
+ t.Fatal("duplicate closure payment")
+ }
+ }
+ // A failed bank save on disconnect remains in the durable pending ledger.
+ table := newTestCashHunt()
+ table.join("alice")
+ wallet := &failingPayoutWallet{testWallet: testWallet{credits: 100}}
+ table.bet("alice", "", 10, wallet)
+ table.markDisconnected("alice")
+ if !table.idle() || len(wallet.open) != 1 {
+ t.Fatal("failed disconnect settlement consumed the stake")
+ }
+}
+
+func TestCashHuntChosenPrizeSurvivesClosure(t *testing.T) {
+ for _, disconnect := range []bool{false, true} {
+ table := newTestCashHunt()
+ table.join("alice")
+ wallet := &failingPayoutWallet{testWallet: testWallet{credits: 100}}
+ table.bet("alice", "", 10, wallet)
+ table.squares[0], table.squares[6] = 0, 200
+ table.action("alice", "7") // selected, but banking failed
+ table.wallet = &wallet.testWallet
+ table.rng = rand.New(cashHuntNoRandom{})
+ if disconnect {
+ table.markDisconnected("alice")
+ } else {
+ table.refundAll()
+ }
+ if !table.idle() || len(wallet.open) != 0 || wallet.paid != 2000 {
+ t.Fatal("closure must settle the chosen prize, not refund or substitute square 1")
+ }
+ }
+}
+
+func TestCashHuntConfigAndBetGuards(t *testing.T) {
+ for _, c := range []TableConfig{
+ {ID: "hunt", Game: GameCashHunt, MaxPlayers: 2},
+ {ID: "hunt", Game: GameCashHunt, MaxBet: MaxCredits/cashHuntMaxMultiplier + 1},
+ {ID: "hunt", Game: GameCashHunt, Plinko: PlinkoConfig{RTP: 98}},
+ } {
+ if c.Validate() == nil {
+ t.Fatalf("invalid config accepted: %+v", c)
+ }
+ }
+ c := TableConfig{ID: "hunt", Game: GameCashHunt, MaxBet: MaxCredits / cashHuntMaxMultiplier}
+ if err := c.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ table := newTestCashHunt()
+ table.join("alice")
+ if table.join("bob")[0].Type == EventJoined {
+ t.Fatal("second player joined")
+ }
+ wallet := &testWallet{credits: 100}
+ for _, amount := range []int{-1, 0, MaxCredits/cashHuntMaxMultiplier + 1} {
+ table.bet("alice", "", amount, wallet)
+ }
+ table.bet("alice", "square 7", 1, wallet)
+ table.bet("bob", "", 1, wallet)
+ table.bet("alice", "", 1, nil)
+ table.bet("alice", "", 1, &testWallet{})
+ if table.active || len(wallet.open) != 0 {
+ t.Fatal("invalid bet changed state")
+ }
+ for risk, name := range cashHuntRisks {
+ table.action("alice", "difficulty "+name)
+ if table.risk != risk {
+ t.Fatal("difficulty did not change")
+ }
+ snapshot := table.action("alice", "paytable")[0].Snapshot.(*CashHuntSnapshot)
+ if len(snapshot.Odds) != 12 || snapshot.Odds[11].Multiplier != 50000 {
+ t.Fatal("incomplete paytable")
+ }
+ }
+ table.bet("alice", "", 1, wallet)
+ if spot, amount := table.rebet("alice"); spot != "" || amount != 1 {
+ t.Fatal("rebet not remembered")
+ }
+}
diff --git a/internal/casino/core.go b/internal/casino/core.go
index 3956750..5631c0f 100644
--- a/internal/casino/core.go
+++ b/internal/casino/core.go
@@ -187,6 +187,9 @@ func settleStakeOutcome(w Wallet, payout int, stakeIDs []string, payoutErr error
// gameTitle renders a configured game name as a message heading.
func gameTitle(game GameName) string {
+ if game == GameCashHunt {
+ return "Cash Hunt"
+ }
if game == GameBigSix {
return "Big 6"
}
diff --git a/internal/casino/plinko.go b/internal/casino/plinko.go
index d73a0a4..412b72c 100644
--- a/internal/casino/plinko.go
+++ b/internal/casino/plinko.go
@@ -4,6 +4,7 @@ import (
"fmt"
"math"
"math/rand"
+ "sort"
"strconv"
"strings"
"sync"
@@ -11,9 +12,7 @@ import (
const plinkoRows = 16
-const plinkoFirstRow = 0
-
-// RTP is the base percentage before pocket multipliers round up to tenths.
+// RTP is the target return percentage for the one-decimal paytables.
// Fixed expert edge prizes alone return 30.517578125%.
type PlinkoConfig struct {
RTP float64 `yaml:"rtp,omitempty"`
@@ -41,10 +40,23 @@ func validatePlinko(c TableConfig) error {
var plinkoRisks = []string{"easy", "medium", "hard", "expert"}
+// Balance in difficulty order so higher risk never reduces actual RTP.
+// Compare exact path-weighted tenths, avoiding floating-point ordering ties.
+func plinkoPockets(risk int, rtp float64) [17]float64 {
+ var pockets [17]float64
+ minimum := 0
+ for difficulty := 0; difficulty <= risk; difficulty++ {
+ pockets, minimum = plinkoBalancedPockets(difficulty, rtp, minimum)
+ }
+ return pockets
+}
+
// Every path has probability 2^-16. Normalize interior weights against the
// binomial distribution, keeping both edge jackpots fixed at every RTP.
-// Round the played multipliers UP to tenths, intentionally raising actual RTP.
-func plinkoPockets(risk int, rtp float64) [17]float64 {
+// Choose floor/ceil tenths jointly, rather than rounding every pocket upward:
+// all 256 symmetric rounding combinations are cheap to check, and the closest
+// monotone paytable tracks the target without inflating the actual return.
+func plinkoBalancedPockets(risk int, rtp float64, minimum int) ([17]float64, int) {
var out [17]float64
outer := []float64{16, 100, 1000, 10000}[risk]
powers := []float64{2, 4, 7, 12}
@@ -55,11 +67,45 @@ func plinkoPockets(risk int, rtp float64) [17]float64 {
out[k] = math.Pow(float64(absPlinko(k-8)+1), powers[risk])
sum += choose * out[k] / 65536
}
- scale := (rtp/100 - 2*outer/65536) / sum
- for k := 1; k < 16; k++ {
- out[k] = math.Ceil(out[k]*scale*10) / 10
+ // If rounding raised a lower risk's return, center this risk's candidates
+ // on that return rather than searching below its required floor.
+ scale := (max(rtp/100, float64(minimum)/655360) - 2*outer/65536) / sum
+ var low, high [9]int
+ for k := 1; k <= 8; k++ {
+ v := out[k] * scale * 10
+ low[k], high[k] = int(math.Floor(v)), int(math.Ceil(v))
+ }
+ // Number of paths to each pocket, mirrored about the center.
+ paths := [9]int{1, 16, 120, 560, 1820, 4368, 8008, 11440, 12870}
+ target := rtp / 100 * 655360 // expected return in path-weighted tenths
+ bestError := math.Inf(1)
+ var best [17]float64
+ bestWeighted := 0
+ for mask := 0; mask < 256; mask++ {
+ candidate := [17]float64{0: outer, 16: outer}
+ weighted := int(outer) * 20
+ valid := true
+ for k := 1; k <= 8; k++ {
+ v := low[k]
+ if mask&(1<<(k-1)) != 0 {
+ v = high[k]
+ }
+ candidate[k], candidate[16-k] = float64(v)/10, float64(v)/10
+ if candidate[k] > candidate[k-1] {
+ valid = false
+ break
+ }
+ copies := 2
+ if k == 8 {
+ copies = 1
+ }
+ weighted += copies * paths[k] * v
+ }
+ if err := math.Abs(float64(weighted) - target); valid && weighted >= minimum && err < bestError {
+ best, bestError, bestWeighted = candidate, err, weighted
+ }
}
- return out
+ return best, bestWeighted
}
func absPlinko(n int) int {
if n < 0 {
@@ -67,6 +113,42 @@ func absPlinko(n int) int {
}
return n
}
+
+// plinkoMultiplierText renders a played multiplier with at most one decimal.
+func plinkoMultiplierText(multiplier float64) string {
+ return strconv.FormatFloat(multiplier, 'f', -1, 64) + "x"
+}
+
+// plinkoReturns splits the actual payout across the distinct landed pockets.
+// Each pocket's exact return is floored; leftover credits go to the largest
+// fractional remainders, so the itemized receipt always sums to the payout.
+func plinkoReturns(counts [17]int, betSize int, pockets [17]float64, paid int) map[int]int {
+ type share struct {
+ pocket, floor int
+ frac float64
+ }
+ var shares []share
+ base := 0
+ for p, count := range counts {
+ if count == 0 {
+ continue
+ }
+ exact := float64(betSize) * pockets[p] * float64(count)
+ floor := int(math.Floor(exact))
+ shares = append(shares, share{pocket: p, floor: floor, frac: exact - float64(floor)})
+ base += floor
+ }
+ sort.SliceStable(shares, func(i, j int) bool { return shares[i].frac > shares[j].frac })
+ for i := 0; i < paid-base && i < len(shares); i++ {
+ shares[i].floor++
+ }
+ returns := make(map[int]int, len(shares))
+ for _, s := range shares {
+ returns[s.pocket] = s.floor
+ }
+ return returns
+}
+
func plinkoReturn(risk int, rtp float64) float64 {
pockets := plinkoPockets(risk, rtp)
choose, result := 1.0, 0.0
@@ -84,21 +166,22 @@ func plinkoHouseEdge(c TableConfig, _ string) HouseEdge {
for risk, name := range plinkoRisks {
e.Parts = append(e.Parts, EdgePart{Label: name, Loss: 1 - plinkoReturn(risk, c.Plinko.Normalize().RTP)})
}
- e.Note = "easy / medium / hard / expert; includes upward rounding to 0.1x"
+ e.Note = "easy / medium / hard / expert; actual returns from one-decimal paytables"
return e
}
func plinkoRules(c TableConfig) string {
- return fmt.Sprintf("Plinko: 16 fair left/right bounces; 17 pockets. Base RTP %.4g%%. Multipliers round UP to 0.1x, increasing actual RTP by risk.\nUse difficulty easy|medium|hard|expert, balls 1-10, then bet N (per ball).\nPocket multipliers INCLUDE your stake. Fractional total returns round randomly, without bias.\nEasy edges pay 16x; expert edges pay 10000x. Higher risk means higher variance.\nOne ball is white; overlapping balls shade toward red (all balls together).\nLeaving is blocked during a drop; disconnect forfeits an unfinished drop.", c.Plinko.Normalize().RTP)
+ return fmt.Sprintf("Plinko: 16 fair left/right bounces; 17 pockets. Target RTP %.4g%%. One-decimal payouts are balanced to track that target.\nUse difficulty easy|medium|hard|expert, balls 1-10, then bet N (per ball).\nPocket multipliers INCLUDE your stake. Fractional total returns round randomly, without bias.\nEasy edges pay 16x; expert edges pay 10000x. Higher risk means higher variance.\nOne ball is white; overlapping balls shade toward red (all balls together).\nLeaving is blocked during a drop; disconnect forfeits an unfinished drop.", c.Plinko.Normalize().RTP)
}
type PlinkoSnapshot struct {
- // Row 0 is the apex (preceded by the payout legend); 16 is the pockets.
+ // Row 0 is the apex; 16 is the pockets. The legend runs alongside the board.
Row, Total int
Counts [17]int
Pockets [17]float64
Paytable bool
Risk string
+ RTP float64 // actual return percentage of Pockets
}
func (*PlinkoSnapshot) snapshot() {}
@@ -180,14 +263,14 @@ func (t *plinkoTable) hints(n string) []MenuCommand {
return t.hintsLocked()
}
func (t *plinkoTable) snap(paytable bool) *PlinkoSnapshot {
- s := &PlinkoSnapshot{Row: t.row, Total: t.balls, Pockets: plinkoPockets(t.risk, t.Config.Plinko.Normalize().RTP), Risk: plinkoRisks[t.risk], Paytable: paytable}
+ s := &PlinkoSnapshot{Row: t.row, Total: t.balls, Pockets: plinkoPockets(t.risk, t.Config.Plinko.Normalize().RTP), Risk: plinkoRisks[t.risk], RTP: 100 * plinkoReturn(t.risk, t.Config.Plinko.Normalize().RTP), Paytable: paytable}
for _, p := range t.positions {
s.Counts[p]++
}
return s
}
func (t *plinkoTable) statusLocked(n string) []Event {
- e := t.private(n, fmt.Sprintf("PLINKO / %s / %d balls / %.2f%% RTP (base %.4g%%)", plinkoRisks[t.risk], t.balls, 100*plinkoReturn(t.risk, t.Config.Plinko.Normalize().RTP), t.Config.Plinko.Normalize().RTP))
+ e := t.private(n, "")
e.Color = "casino_menu"
e.Snapshot = t.snap(true)
if n == t.name {
@@ -210,7 +293,7 @@ func (t *plinkoTable) join(n string) []Event {
t.name = n
t.risk = 0
t.balls = 1
- e := t.playerAction(n, n+" steps up to Plinko.", "Welcome to PLINKO! Choose your risk and let it rain.")
+ e := t.private(n, "")
e.Type = EventJoined
e.Player = n
return append([]Event{e}, t.statusLocked(n)...)
@@ -304,10 +387,10 @@ func (t *plinkoTable) bet(n, spot string, amount int, w Wallet) []Event {
t.wallet = w
t.stake = stake
t.betSize = amount
- t.row = plinkoFirstRow
+ t.row = 0
t.active = true
t.positions = make([]int, t.balls)
- e := t.private(n, fmt.Sprintf("*** PLINKO / %s *** %d balls x %d = %d credits. Let it rain!", strings.ToUpper(plinkoRisks[t.risk]), t.balls, amount, stake.Total))
+ e := t.private(n, fmt.Sprintf("Stake: %d x %d = %d credits.", t.balls, amount, stake.Total))
e.Snapshot = t.snap(false)
return []Event{e}
}
@@ -318,10 +401,8 @@ func (t *plinkoTable) tick() []Event {
return nil
}
t.row++
- if t.row > 0 {
- for i := range t.positions {
- t.positions[i] += t.rng.Intn(2)
- }
+ for i := range t.positions {
+ t.positions[i] += t.rng.Intn(2)
}
e := t.private(t.name, "")
e.Snapshot = t.snap(false)
@@ -333,11 +414,10 @@ func (t *plinkoTable) tick() []Event {
pockets := plinkoPockets(t.risk, t.Config.Plinko.Normalize().RTP)
labels := PlinkoPocketLabels(pockets)
total := 0.0
- var hits []string
- for i, p := range t.positions {
- v := pockets[p] * float64(t.betSize)
- total += v
- hits = append(hits, fmt.Sprintf("Ball %d -> [%c] %.1fx", i+1, labels[p], pockets[p]))
+ var counts [17]int
+ for _, p := range t.positions {
+ counts[p]++
+ total += pockets[p] * float64(t.betSize)
}
paid := int(math.Floor(total))
if t.rng.Float64() < total-float64(paid) {
@@ -348,19 +428,44 @@ func (t *plinkoTable) tick() []Event {
}
t.stake = Wager{}
result := t.private(t.name, "")
- outcome := "loss"
- if paid == t.betSize*t.balls {
- outcome = "push"
- } else if paid > t.betSize*t.balls {
- outcome = "win"
+ // Itemize the receipt by landed payout so each distinct multiplier is a
+ // visible row, instead of a separate "Ball N -> [X]" line per ball.
+ returns := plinkoReturns(counts, t.betSize, pockets, paid)
+ type payoutRow struct {
+ multiplier float64
+ stake, returned int
+ }
+ rows := make(map[rune]*payoutRow)
+ var order []rune
+ for p, count := range counts {
+ if count == 0 {
+ continue
+ }
+ row, ok := rows[labels[p]]
+ if !ok {
+ row = &payoutRow{multiplier: pockets[p]}
+ rows[labels[p]] = row
+ order = append(order, labels[p])
+ }
+ row.stake += t.betSize * count
+ row.returned += returns[p]
+ }
+ result.Settlement = make([]SettlementLine, 0, len(order))
+ for _, label := range order {
+ row := rows[label]
+ result.Settlement = append(result.Settlement, SettlementLine{
+ Label: fmt.Sprintf("[%c] %s", label, plinkoMultiplierText(row.multiplier)),
+ Odds: "Varies",
+ Stake: row.stake,
+ Returned: row.returned,
+ Result: returnResult(row.stake, row.returned),
+ })
}
- result.Settlement = []SettlementLine{{Label: fmt.Sprintf("%d Plinko balls", t.balls), Odds: "Varies", Stake: t.betSize * t.balls, Returned: paid, Result: outcome}}
result.Color = "casino_payout"
if paid > t.betSize*t.balls {
result.Color = "casino_big_win"
}
- // The shared receipt replaces Message, so keep pocket reveals in their own event.
- events = append(events, t.private(t.name, strings.Join(hits, "\n")), result, t.betMenuEvent(t.name, t.hintsLocked()))
+ events = append(events, result, t.betMenuEvent(t.name, t.hintsLocked()))
return events
}
func (t *plinkoTable) markDisconnected(n string) []Event {
diff --git a/internal/casino/plinko_test.go b/internal/casino/plinko_test.go
index 2b79853..6ddfee5 100644
--- a/internal/casino/plinko_test.go
+++ b/internal/casino/plinko_test.go
@@ -27,8 +27,14 @@ func TestPlinkoRoundedRTPAndVariance(t *testing.T) {
t.Fatalf("pockets must grow toward edges: %v", p)
}
}
- if mean < rtp/100 || mean >= rtp/100+0.1 {
- t.Fatalf("risk %d rounded RTP %g not within [base, base+0.1)", risk, mean)
+ if math.Abs(mean-rtp/100) > 0.01 {
+ t.Fatalf("risk %d RTP %.4f%% is too far from target %.2f%%", risk, mean*100, rtp)
+ }
+ if rtp == 98 {
+ if mean < 0.97 || mean > 0.99 {
+ t.Fatalf("risk %d default RTP %.4f%% outside 97–99%%", risk, mean*100)
+ }
+ t.Logf("%s RTP: %.6f%%", plinkoRisks[risk], mean*100)
}
if math.Abs(mean-plinkoReturn(risk, rtp)) > 1e-12 {
t.Fatal("reported RTP differs from played pockets")
@@ -49,6 +55,20 @@ func TestPlinkoRoundedRTPAndVariance(t *testing.T) {
}
}
}
+func TestPlinkoRTPNeverFallsWithRisk(t *testing.T) {
+ // Include fractional builder-configured targets as well as the default.
+ for target := 31.0; target <= 100; target += 0.25 {
+ previous := 0.0
+ for risk := range plinkoRisks {
+ actual := plinkoReturn(risk, target)
+ if actual+1e-12 < previous {
+ t.Fatalf("target %.2f%%: %s RTP %.6f%% fell below %.6f%%", target, plinkoRisks[risk], actual*100, previous*100)
+ }
+ previous = actual
+ }
+ }
+}
+
func newTestPlinko() *plinkoTable {
return newTable(1, TableConfig{ID: "plinko", Game: GamePlinko}, rand.New(rand.NewSource(42))).game.(*plinkoTable)
}
@@ -68,7 +88,7 @@ func TestPlinkoDropLifecycle(t *testing.T) {
if !p.active || p.balls != 10 || len(w.open) != 1 {
t.Fatal("active drop changed")
}
- for row := plinkoFirstRow + 1; row <= 16; row++ {
+ for row := 1; row <= 16; row++ {
old := append([]int(nil), p.positions...)
events := p.tick()
s := events[0].Snapshot.(*PlinkoSnapshot)
@@ -80,9 +100,6 @@ func TestPlinkoDropLifecycle(t *testing.T) {
t.Fatal("wrong animation")
}
for i, pos := range p.positions {
- if row <= 0 && pos != old[i] {
- t.Fatal("ball moved during label prelude")
- }
if pos-old[i] < 0 || pos-old[i] > 1 {
t.Fatal("not a left/right bounce")
}
@@ -130,10 +147,11 @@ func TestPlinkoRoundedPocketsArePaidAndAnalyzed(t *testing.T) {
p.action("alice", "difficulty expert")
w := &testWallet{credits: 100}
p.bet("alice", "", 10, w)
- // Both possible final center pockets round up to 0.1x, not just in the UI.
+ // Expert center pockets pay 0x; the displayed one-decimal table is played
+ // exactly, without silently rounding a losing pocket upward.
p.row, p.positions = 15, []int{7}
events := p.tick()
- if w.paid != 1 || len(w.open) != 0 {
+ if w.paid != 0 || len(w.open) != 0 {
t.Fatalf("rounded pocket not paid: %+v", w)
}
for _, e := range events {
@@ -172,14 +190,86 @@ func TestPlinkoSharedPocketLabels(t *testing.T) {
p.bet("alice", "", 1, w)
p.row, p.positions = 15, []int{15}
events := p.tick()
- labels := PlinkoPocketLabels(plinkoPockets(0, 98))
- want := fmt.Sprintf("Ball 1 -> [%c]", labels[p.positions[0]])
+ pockets := plinkoPockets(0, 98)
+ labels := PlinkoPocketLabels(pockets)
+ want := fmt.Sprintf("[%c] %s", labels[p.positions[0]], plinkoMultiplierText(pockets[p.positions[0]]))
found := false
for _, e := range events {
- found = found || strings.Contains(e.Message, want)
+ for _, line := range e.Settlement {
+ found = found || line.Label == want
+ }
}
if !found {
- t.Fatalf("settlement did not use mirrored label %s", want)
+ t.Fatalf("settlement did not itemize mirrored label %s", want)
+ }
+}
+
+func TestPlinkoReturnsItemizeEveryPocket(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}
+ counts := [17]int{0: 1, 7: 2, 16: 1}
+ total := 0.0
+ for p, count := range counts {
+ total += pockets[p] * float64(count) * 3
+ }
+ paid := int(math.Floor(total))
+ returns := plinkoReturns(counts, 3, pockets, paid)
+ sum := 0
+ for p, count := range counts {
+ if count == 0 {
+ continue
+ }
+ if returns[p] < 0 {
+ t.Fatalf("negative return for pocket %d", p)
+ }
+ sum += returns[p]
+ }
+ if sum != paid {
+ t.Fatalf("itemized returns %d do not sum to paid %d", sum, paid)
+ }
+ if returns[16] <= returns[7] {
+ t.Fatalf("higher pocket %d must out-return %d", returns[16], returns[7])
+ }
+ if whole := plinkoReturns([17]int{0: 2}, 5, pockets, 160); whole[0] != 160 {
+ t.Fatalf("whole return changed: %d", whole[0])
+ }
+}
+
+func TestPlinkoReceiptMergesSharedPayouts(t *testing.T) {
+ p := newTestPlinko()
+ p.join("alice")
+ p.action("alice", "balls 4")
+ w := &testWallet{credits: 100}
+ p.bet("alice", "", 5, w)
+ if len(w.open) != 1 || w.open[0].Total != 20 {
+ t.Fatal("batch stake wrong")
+ }
+ // Mirrored pockets 1 and 15 share payout B; two balls in center pocket 8.
+ // newTestPlinko seeds the RNG at 42 and the drop is its first consumer, so
+ // undo the first four bounces to land the sealed balls on named pockets.
+ bounces := rand.New(rand.NewSource(42))
+ p.positions = []int{1 - bounces.Intn(2), 15 - bounces.Intn(2), 8 - bounces.Intn(2), 8 - bounces.Intn(2)}
+ p.row = 15
+ events := p.tick()
+ if p.positions[0] != 1 || p.positions[1] != 15 {
+ t.Fatalf("unexpected final pockets: %v", p.positions)
+ }
+ var lines []SettlementLine
+ for _, e := range events {
+ lines = append(lines, e.Settlement...)
+ }
+ if len(lines) != 2 {
+ t.Fatalf("want one row per distinct payout, got %d: %+v", len(lines), lines)
+ }
+ staked, returned := 0, 0
+ for _, line := range lines {
+ staked += line.Stake
+ returned += line.Returned
+ }
+ if staked != 20 || returned != 80 || w.paid != 80 {
+ t.Fatalf("merged receipt mismatch: staked %d returned %d paid %d", staked, returned, w.paid)
+ }
+ if lines[0].Label != "[B] 7.8x" || lines[0].Returned != 78 || lines[1].Label != "[I] 0.2x" || lines[1].Returned != 2 {
+ t.Fatalf("unexpected rows: %+v", lines)
}
}
@@ -205,7 +295,7 @@ func TestPlinkoFailedPayoutRetainsStake(t *testing.T) {
p.join("alice")
w := &failingPayoutWallet{testWallet: testWallet{credits: 100}}
p.bet("alice", "", 10, w)
- for i := plinkoFirstRow; i < 16; i++ {
+ for i := 0; i < 16; i++ {
p.tick()
}
if len(w.open) != 1 {
diff --git a/internal/casino/registry.go b/internal/casino/registry.go
index fdaecec..b13125d 100644
--- a/internal/casino/registry.go
+++ b/internal/casino/registry.go
@@ -44,6 +44,15 @@ var tableGames map[GameName]tableGameDef
func init() {
tableGames = map[GameName]tableGameDef{
+ GameCashHunt: {
+ validate: validateCashHunt,
+ normalize: func(c TableConfig) TableConfig { c.MaxPlayers = 1; return c },
+ hasRuleBlock: func(TableConfig) bool { return false },
+ new: newCashHuntTable,
+ ruleText: cashHuntRules,
+ helpTopic: func(TableConfig) string { return "cash hunt" },
+ houseEdge: func(TableConfig, string) HouseEdge { return edge(0.02) },
+ },
GamePlinko: {
validate: validatePlinko,
normalize: func(c TableConfig) TableConfig { c.Plinko = c.Plinko.Normalize(); c.MaxPlayers = 1; return c },
diff --git a/internal/casino/types.go b/internal/casino/types.go
index 5f4d4f8..f6a828d 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -18,6 +18,7 @@ const (
GameVideoPoker GameName = "video_poker"
GameSlots GameName = "slots"
GamePlinko GameName = "plinko"
+ GameCashHunt GameName = "cash_hunt"
GameBlackjack GameName = "blackjack"
GameBaccarat GameName = "baccarat"
GameRoulette GameName = "roulette"
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))
}