diff options
| author | historia <[not public]> | 2026-10-04 17:44:27 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-04 17:44:27 -0400 |
| commit | 93dcb67ffee05ebf1de0c9a78a8b906eaf161007 (patch) | |
| tree | 88cdd3d732f11eee616698a9ca32f699d2b41d77 | |
| parent | 3a39a020e4449bc4d6f728ef837cffb9f7bd0c56 (diff) | |
| download | thehouseoficarus-93dcb67ffee05ebf1de0c9a78a8b906eaf161007.tar.gz | |
feat: new casino game: cash hunt, fixed verb issue with pai gow
| -rw-r--r-- | building_guide/rooms.md | 16 | ||||
| -rw-r--r-- | data/help/cash_hunt.yaml | 41 | ||||
| -rw-r--r-- | data/help/casino.yaml | 5 | ||||
| -rw-r--r-- | data/help/plinko.yaml | 25 | ||||
| -rw-r--r-- | data/rooms/casino/6200.yaml | 4 | ||||
| -rw-r--r-- | data/rooms/casino/6201.yaml | 10 | ||||
| -rw-r--r-- | internal/casino/cash_hunt.go | 342 | ||||
| -rw-r--r-- | internal/casino/cash_hunt_test.go | 265 | ||||
| -rw-r--r-- | internal/casino/core.go | 3 | ||||
| -rw-r--r-- | internal/casino/plinko.go | 173 | ||||
| -rw-r--r-- | internal/casino/plinko_test.go | 116 | ||||
| -rw-r--r-- | internal/casino/registry.go | 9 | ||||
| -rw-r--r-- | internal/casino/types.go | 1 | ||||
| -rw-r--r-- | internal/game/cash_hunt_test.go | 178 | ||||
| -rw-r--r-- | internal/game/casino.go | 17 | ||||
| -rw-r--r-- | internal/game/casino_ux.go | 2 | ||||
| -rw-r--r-- | internal/game/cmd_registry.go | 47 | ||||
| -rw-r--r-- | internal/game/game.go | 10 | ||||
| -rw-r--r-- | internal/game/look_casino.go | 6 | ||||
| -rw-r--r-- | internal/game/look_target.go | 3 | ||||
| -rw-r--r-- | internal/game/pai_gow_test.go | 20 | ||||
| -rw-r--r-- | internal/game/plinko_test.go | 130 | ||||
| -rw-r--r-- | internal/game/render_cash_hunt.go | 110 | ||||
| -rw-r--r-- | internal/game/render_plinko.go | 103 |
24 files changed, 1480 insertions, 156 deletions
diff --git a/building_guide/rooms.md b/building_guide/rooms.md index f1e1712..31e45f0 100644 --- a/building_guide/rooms.md +++ b/building_guide/rooms.md @@ -255,6 +255,22 @@ Multiplayer table fields (`casino_tables`): Game rules are nested in a block named after the game. `id` and `game` are the only required keys; omitted numeric fields keep their defaults. +#### Cash Hunt + +Cash Hunt is a single-seat fixture, addressed with `play cash hunt`: + +```yaml +casino_tables: + - id: cash_hunt + game: cash_hunt + min_bet: 1 + max_bet: 10000 +``` + +No game-specific rule block is needed. All three player-selectable difficulties +have fixed 98% RTP. `max_players` must be omitted, `0`, or `1`; `max_bet` +cannot exceed 20000000 because the largest prize is 50000x. + #### Plinko Plinko is a single-seat fixture under `casino_tables`, addressed with `play plinko`: diff --git a/data/help/cash_hunt.yaml b/data/help/cash_hunt.yaml new file mode 100644 index 0000000..734a156 --- /dev/null +++ b/data/help/cash_hunt.yaml @@ -0,0 +1,41 @@ +name: cash hunt +aliases: [cashhunt, cash_hunt, cash-hunt] +category: Interaction +related: [casino, plinko] +description: | + CASH HUNT — 100 squares. One choice. + + play cash hunt Take the single seat. + difficulty easy Choose easy, medium or hard (default easy). + paytable Show exact per-square prize probabilities. + bet 10 Commit 10 credits and reveal the prize board. + 73 / pick 73 Unlock square 73 after the positions are mixed. + bet / rebet Repeat your last wager. + table Check the current phase or last revealed result. + stop Leave after the hunt has settled. + + Each wager generates 100 prizes and shows the ENTIRE numbered board. + The prizes are then hidden and their positions uniformly shuffled. + The final positions are fixed BEFORE you enter your choice: choosing a + square reveals its stored prize, not a fresh random roll. The final + board shows every prize and highlights your selected square with >73<. + Preview numbers do not identify the prizes' final positions. + Tiles use a dark rainbow gradient, one hue per column. + + All three difficulties have exactly 98% theoretical RTP and a 2% house + edge. Harder games move more return into rare large prizes, with more + zero-return squares. Each board's mixture and average return vary. + You must wager before seeing the preview and cannot cancel afterward. + + Prizes: 0x, 1x, 2x, 5x, 10x, 20x, 100x, 200x, 500x, 1000x, + 10000x and 50000x. Large prizes are possible, not guaranteed; there may + be none, one, or several. RTP includes the very rare jackpots. + The paytable lists exact probabilities for your chosen difficulty. + Multipliers INCLUDE your stake: 0x loses it, 1x returns it, 2x doubles it. + + Settings and your stake are locked during a hunt. A failed payout keeps + your selection locked; settle retries that SAME prize without rerolling. + Disconnecting automatically settles square 1 if you have not picked; + if you already picked, it retries that selection instead. Shutdown + refunds unpicked boards and attempts to settle already chosen prizes. + Failed bank saves leave stakes pending for recovery, never a paid receipt. diff --git a/data/help/casino.yaml b/data/help/casino.yaml index e1ce06d..478a742 100644 --- a/data/help/casino.yaml +++ b/data/help/casino.yaml @@ -1,7 +1,7 @@ name: casino aliases: [play, bet, rebet, table, watch, rules] category: Interaction -related: [big6, roulette, racetrack, blackjack, baccarat, craps, slots, video poker, pai gow poker, plinko] +related: [big6, roulette, racetrack, blackjack, baccarat, craps, slots, video poker, pai gow poker, plinko, cash hunt] description: | Casino quick guide @@ -18,7 +18,7 @@ description: | Profit odds exclude your returned stake: a 10-credit win at 2:1 returns 30 total (20 profit + 10 stake). A push returns the stake; - a loss returns nothing. Slots, Plinko and video poker paytables use total-return + a loss returns nothing. Slots, Plinko, Cash Hunt and video poker paytables use total-return multipliers; the shared payout receipt uses profit odds instead. Fractional table-game returns round down to whole credits, except craps, which rounds stakes up to exact payout units (commission rounds down), @@ -45,6 +45,7 @@ description: | help craps Multiplayer dice, free odds, Fire and ATS bets. help slots Matching symbols, ways and free spins. help plinko Animated pyramid, four risk levels, up to ten balls. + help cash hunt Reveal 100 prizes, mix their positions, pick one square. help video poker Five cabinet types, selectable games, toggle holds and bonuses. help pai gow poker Seven cards, two hands; Classic, EZ, No Push and Fortune/Envy. diff --git a/data/help/plinko.yaml b/data/help/plinko.yaml index 4691cd6..486a87b 100644 --- a/data/help/plinko.yaml +++ b/data/help/plinko.yaml @@ -20,24 +20,29 @@ description: | per tick, without clearing your screen. Balls fall between pegs. Equal payouts share a letter: normally A B C D E F G H I H G F E D C B A. Rounded ties also share letters, so some risks use fewer than nine labels. - A single dim-white ruler of pocket letters prints once, at the board's - midpoint, marking the fixed pocket columns; the base repeats it in - brackets. Hints mark positions, not a prediction of the result. + Dim-gray pocket letters appear below the midpoint, without hiding balls + or pegs. The base repeats the letters in brackets; landing pockets light + up as >A< instead of [A]. Hints mark positions, not predicted results. The outside A pockets pay 16x on easy, 100x on medium, 1000x on hard and 10000x on expert. Multipliers INCLUDE your stake. Each outer pocket has a 1/65536 chance. - Higher difficulty has much higher variance. Upward payout rounding also - makes the actual expected return differ slightly between difficulties. + Higher difficulty has much higher variance, including zero-return pockets. + The one-line paytable shows the actual RTP for your selected difficulty. + Actual RTP never decreases as difficulty increases. A single ball is white. Overlapping balls become pink, then pure red when all released balls meet. Basic ANSI approximates these shades. Without color, overlaps show 2-9 or * for ten balls. - The room's configured BASE return defaults to 98% at every difficulty. - Interior multipliers adjust to that base, then round UP to the next 0.1x; - the outside prizes stay fixed. The displayed multipliers are what pays. - Rounding increases actual RTP (possibly above 100%); the menu shows the - actual return for your selected difficulty as well as the configured base. + The receipt after each drop itemizes the result by landed payout: one + row per distinct multiplier, with its staked amount and net (mirrored + pockets share a row). The board has no fixed background, so your + terminal theme shows through. + + The room's configured target return defaults to 98% at every difficulty. + Interior multipliers are balanced in 0.1x steps to stay close to that + target; the outside prizes stay fixed. The displayed multipliers are + exactly what pays. Returns include your stake. Fractional batch winnings round randomly to adjacent whole credits, preserving the expected return even for one-credit bets. diff --git a/data/rooms/casino/6200.yaml b/data/rooms/casino/6200.yaml index 0e35a17..8b31dcc 100644 --- a/data/rooms/casino/6200.yaml +++ b/data/rooms/casino/6200.yaml @@ -1,6 +1,8 @@ name: Crimson Rain Plinko Salon -description: A luminous pyramid rises above a row of jewel-bright prize pockets. Polished pins scatter falling balls into a rain of white and crimson sparks, while enormous edge jackpots burn like miniature suns. A brass plaque invites you to choose your risk and let it rain. The Sword and Shield wheel salon lies south. +description: A luminous pyramid rises above a row of jewel-bright prize pockets. Polished pins scatter falling balls into a rain of white and crimson sparks, while enormous edge jackpots burn like miniature suns. A brass plaque invites you to choose your risk and let it rain. The Sword and Shield wheel salon lies south; the Kaleidoscope Vault glitters to the north. exits: + north: + room: 6201 south: room: 6199 casino_tables: diff --git a/data/rooms/casino/6201.yaml b/data/rooms/casino/6201.yaml new file mode 100644 index 0000000..9708f89 --- /dev/null +++ b/data/rooms/casino/6201.yaml @@ -0,0 +1,10 @@ +name: The Kaleidoscope Vault +description: One hundred jewel-lit shutters cover a wall of polished midnight glass. Behind each waits a prize, from an empty pocket to a fortune bright enough to light the entire casino. When the vault shuffles, ribbons of violet and gold sweep across the sealed squares. A small plaque reads, "One choice. Your fortune." The Crimson Rain Plinko Salon lies south. +exits: + south: + room: 6200 +casino_tables: + - id: cash_hunt + game: cash_hunt + min_bet: 1 + max_bet: 10000 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)) } |
