From 9fb1f9010526100e12dd1ab1b0ca98401bb6ed75 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sat, 10 Oct 2026 04:08:09 -0400 Subject: feat: add casino game: crazy time --- building_guide/rooms.md | 21 +- data/help/big6.yaml | 2 +- data/help/casino.yaml | 3 +- data/help/crazy_time.yaml | 65 ++ data/help/roulette.yaml | 6 +- data/rooms/casino/6203.yaml | 2 + data/rooms/casino/6204.yaml | 22 + internal/casino/big_six.go | 58 +- internal/casino/command_hints_test.go | 2 +- internal/casino/core.go | 3 + internal/casino/craps.go | 4 +- internal/casino/craps_test.go | 14 + internal/casino/crazy_time.go | 1440 +++++++++++++++++++++++++++++++++ internal/casino/crazy_time_test.go | 323 ++++++++ internal/casino/pai_gow.go | 4 +- internal/casino/registry.go | 9 + internal/casino/roulette.go | 148 +++- internal/casino/roulette_test.go | 12 +- internal/casino/table.go | 9 +- internal/casino/table_hints.go | 4 +- internal/casino/table_ux.go | 19 + internal/casino/types.go | 1 + internal/casino/video_poker.go | 2 +- internal/game/casino.go | 23 +- internal/game/casino_ux.go | 2 + internal/game/cmd_registry.go | 1 + internal/game/crazy_time_test.go | 193 +++++ internal/game/look_casino.go | 4 +- internal/game/look_target.go | 2 +- internal/game/render_crazy_time.go | 487 +++++++++++ 30 files changed, 2822 insertions(+), 63 deletions(-) create mode 100644 data/help/crazy_time.yaml create mode 100644 data/rooms/casino/6204.yaml create mode 100644 internal/casino/crazy_time.go create mode 100644 internal/casino/crazy_time_test.go create mode 100644 internal/game/crazy_time_test.go create mode 100644 internal/game/render_crazy_time.go diff --git a/building_guide/rooms.md b/building_guide/rooms.md index c61e710..423133e 100644 --- a/building_guide/rooms.md +++ b/building_guide/rooms.md @@ -246,7 +246,7 @@ Multiplayer table fields (`casino_tables`): | Field | Type | Default | Notes | |---|---|---|---| | `id` | string | — | Required. Nonempty, no surrounding whitespace, unique among the room's tables and machines. | -| `game` | string | — | Required. `blackjack`, `baccarat`, `roulette`, `craps`, `paigow`, `video_poker`, `plinko`, `pachinko`, `cash_hunt`, or `coin_flip`. | +| `game` | string | — | Required. `blackjack`, `baccarat`, `roulette`, `craps`, `paigow`, `video_poker`, `plinko`, `pachinko`, `cash_hunt`, `coin_flip`, or `crazy_time`. | | `max_players` | int | 8 | 0–64. Video poker is forced to 1; Pai Gow caps at 6. | | `betting_ticks` | int | 20 | 0–3600. | | `min_bet` | int | 1 | Must not exceed `max_bet`. | @@ -332,6 +332,25 @@ of `N` reserves `100 * N` from the wallet, so the player must hold 100x their stake. `max_players` must be omitted, `0`, or `1`, and `max_bet` cannot exceed `MaxCredits / 100`. +#### Crazy Time + +Crazy Time is a single-seat betting wheel under `casino_tables`, addressed +with `play crazy time`: + +```yaml +casino_tables: + - id: crazy_time + game: crazy_time + min_bet: 1 + max_bet: 10000 +``` + +No game-specific rule block is needed. The 54-pocket wheel pays 1:1, 2:1, +5:1 and 10:1 on the numbers and stops on one of four pure bonus games +(Coin Flip, Cash Hunt, Pachinko or Crazy Time) for a positive multiplier. +A per-round Top Slot can multiply a matching win. Every target returns +exactly 98%. `max_players` must be omitted, `0`, or `1`. + #### Blackjack ```yaml diff --git a/data/help/big6.yaml b/data/help/big6.yaml index 1d0b5cf..3c795ad 100644 --- a/data/help/big6.yaml +++ b/data/help/big6.yaml @@ -14,7 +14,7 @@ description: | Target names accept any unambiguous prefix, so "sw", "swo" and "sh" all work (and "1" is the $1 symbol, never a prefix of $10). - bet / rebet Add another copy of your most recent wager + bet / rebet Repeat all your last wagers spin Start the wheel after placing your bets, when alone (any unambiguous prefix works, so 'sp' spins here). unbet Take back ALL bets before spinning diff --git a/data/help/casino.yaml b/data/help/casino.yaml index 74b1250..799fec3 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, pachinko, cash hunt, coin flip, predictions, crypto] +related: [big6, crazy time, roulette, racetrack, blackjack, baccarat, craps, slots, video poker, pai gow poker, plinko, pachinko, cash hunt, coin flip, predictions, crypto] description: | Casino quick guide @@ -56,6 +56,7 @@ description: | help pachinko Rectangle of pins; populated board; pick a top slot; DOUBLE chains prizes. help cash hunt Reveal 100 prizes, mix their positions, pick one square. help coin flip Fair 50/50 coin; red 0 to -100x, blue 0 to +100x; reserves 100x the stake. + help crazy time 54 pockets, a Top Slot multiplier and four positive bonus games. 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/crazy_time.yaml b/data/help/crazy_time.yaml new file mode 100644 index 0000000..f82d539 --- /dev/null +++ b/data/help/crazy_time.yaml @@ -0,0 +1,65 @@ +name: crazy time +aliases: [crazytime, crazy_time, crazy-time, crazy] +category: Interaction +related: [casino, big6, cash hunt, pachinko, coin flip] +description: | + CRAZY TIME — a 54-pocket wheel and four bonus games. + + Find the coliseum north of the Two Moons Flip. + + play crazy time Take the single seat. + bet 10 on 5 Bet 10 credits on the $5 pocket. + bet 5 on cash hunt Add a bet on a bonus slice (any prefix works: + co / COIN FLIP, ca / CASH HUNT, p / PACHINKO, + cr / CRAZY TIME). + bet 5 on all Wager 5 on each of the four minigames. + bet / rebet Repeat all your last wagers. + spin Start the wheel after placing your bets. + unbet Take back ALL wagers before spinning. + table See the felt and payouts. + list List the eight betting targets. + rules Read this page. + stop Leave, or wait for the wheel to settle. + + Place any number of wagers on any combination of the eight symbols; + repeated bets on one symbol are allowed. Betting has no deadline: type + spin when ready. No new bets once the wheel turns. + + Pockets Symbol Payout + 21 $1 1:1 + 13 $2 2:1 + 7 $5 5:1 + 4 $10 10:1 + 4 COIN FLIP bonus + 2 CASH HUNT bonus + 2 PACHINKO bonus + 1 CRAZY TIME bonus + + Every spin also runs the TOP SLOT first: it picks one of the eight + symbols and a multiplier, then the main wheel turns. If the wheel stops + on the same symbol, your win is multiplied. The Top Slot is tuned per + symbol so that every target — number or bonus — returns exactly 98%. + + The four bonuses are PURE multipliers and never take anything back: + + COIN FLIP Two prizes are drawn (a low one from 2-5 and a high one + from 7-100), assigned to the red and blue faces, and a fair + coin picks one. 2x to 100x. + CASH HUNT 108 tiles, each a positive multiplier up to 500x. The board + is revealed, sealed, its positions are mixed, and one tile + is unlocked. + PACHINKO Sixteen slots carry positive prizes and one DOUBLE. The + prize row prints at the top, the puck drops into a random + slot, and the prize row prints again at the bottom with the + landing marked. A DOUBLE doubles every prize and drops again. + CRAZY TIME A 64-stop wheel with three pointers and DOUBLE respins. It + is spun longer than the main wheel; a randomly chosen + pointer decides the win. + + The four bonus multipliers are always positive, so a bonus bet can never + lose more than its stake and usually returns a multiple of it. The house + edge lives entirely in the main wheel's probabilities and the Top Slot. + + One seat only. Bets are committed when the wheel starts. Shutdown + refunds an unsettled round; a failed payout keeps its stakes pending and + settle retries the same outcome. diff --git a/data/help/roulette.yaml b/data/help/roulette.yaml index 33139de..a425484 100644 --- a/data/help/roulette.yaml +++ b/data/help/roulette.yaml @@ -18,7 +18,7 @@ description: | bet 10 on 1to12 1to12=1-12, 13to24=13-24, 25to36=25-36 (2:1) bet 10 on row1 row1=3,6,...; row2=2,5,...; row3=1,4,... (2:1) - bet / rebet Repeat your previous wager + bet / rebet Repeat all your last wagers bet min / bet max Change amount, retaining the previous target stop Leave once your wager settles (queued if committed) watch roulette Watch table graphics without joining @@ -62,8 +62,8 @@ description: | zero. The house edge is 2.70% or 5.26%, except the American basket (7.89%). Call-bet rounding can increase the edge for other stake sizes. - One wager per player per spin; a combination or call bet is still one - wager. + Place any number of wagers per spin; a combination or call bet is still + one wager. Zero and double zero lose all outside bets. Chips are spent first; winnings return as credits. Displayed returns include your stake; odds above are profit odds. diff --git a/data/rooms/casino/6203.yaml b/data/rooms/casino/6203.yaml index 4e73141..4e4d8ce 100644 --- a/data/rooms/casino/6203.yaml +++ b/data/rooms/casino/6203.yaml @@ -3,6 +3,8 @@ description: Two enormous coins hang from the ceiling, one veined in crimson and exits: south: room: 6202 + north: + room: 6204 casino_tables: - id: coin_flip game: coin_flip diff --git a/data/rooms/casino/6204.yaml b/data/rooms/casino/6204.yaml new file mode 100644 index 0000000..025a456 --- /dev/null +++ b/data/rooms/casino/6204.yaml @@ -0,0 +1,22 @@ +name: Crazy Time Coliseum +description: A colossal wheel of fifty-four glowing pockets dominates the hall, ringed by four + spotlit stages. At the first, two coins turn above a crimson-and-blue felt; at the second, a wall + of jewel-bright shutters waits behind midnight glass; at the third, a tall pachinko rectangle + glitters with silver pins; and at the fourth, a three-pointer prize wheel turns under a rotating + beam of light. A brass rail reads, "One wheel. Four games. Always your multiplier." +exits: + south: + room: 6203 +casino_tables: + - id: crazy_time + game: crazy_time + min_bet: 1 + max_bet: 10000 +objects: +- name: crazy time plaque + description: 'Play crazy time; bet 10 on 5 or bet 5 on cash hunt, or bet 5 on all to + cover every minigame, then spin. The wheel pays 1:1, 2:1, 5:1 and 10:1 on the + numbers, and can stop on COIN FLIP, CASH HUNT, PACHINKO or CRAZY TIME for a positive + bonus multiplier. A Top Slot can multiply a matching win. Every target returns 98%. + list shows every bet; table shows the felt; rules opens the full help page.' + inroom_description: A brass plaque explains the wheel and its four bonus stages. diff --git a/internal/casino/big_six.go b/internal/casino/big_six.go index 36e9c1d..0f4f764 100644 --- a/internal/casino/big_six.go +++ b/internal/casino/big_six.go @@ -99,6 +99,7 @@ type bigSixBet struct { type bigSixPlayer struct { Participant bets []bigSixBet + lastBets []rebetWager lastSpot string lastBet int } @@ -192,8 +193,8 @@ func (t *bigSixTable) commands(name string) []MenuCommand { var c []MenuCommand if p := t.players.get(name); p != nil && t.phase != phasePlaying { c = append(c, MenuCommand{Command: "bet N on BET", Desc: "Add a wager"}) - if p.lastBet > 0 { - c = append(c, MenuCommand{Command: "bet / rebet", Desc: "Repeat your last wager"}) + if len(p.lastBets) > 0 { + c = append(c, MenuCommand{Command: "bet / rebet", Desc: "Repeat all your last wagers"}) } if p.HasBet { c = append(c, MenuCommand{Command: "unbet", Desc: "Take back all wagers"}) @@ -249,31 +250,39 @@ func (t *bigSixTable) leave(name string) []Event { func (t *bigSixTable) bet(name, spot string, amount int, w Wallet) []Event { t.mu.Lock() defer t.mu.Unlock() + events, _ := t.betLocked(name, spot, amount, w) + return events +} + +// betLocked is the locked implementation of bet, shared with the multi-wager +// replay so a rebet holds the table lock across every stake. It reports false +// when no wager was placed. +func (t *bigSixTable) betLocked(name, spot string, amount int, w Wallet) ([]Event, bool) { p := t.players.get(name) if p == nil { - return []Event{t.private(name, "You aren't playing Big 6.")} + return []Event{t.private(name, "You aren't playing Big 6.")}, false } if t.phase == phasePlaying { - return []Event{t.private(name, "No more bets! The wheel is spinning.")} + return []Event{t.private(name, "No more bets! The wheel is spinning.")}, false } if spot == "" { spot = p.lastSpot } resolved, ok := bigSixResolveTarget(spot) if !ok { - return []Event{t.private(name, "Bet on 1, 2, 5, 10, 20, SWORD or SHIELD — an unambiguous prefix is fine. DOUBLE is automatic, not a bet.")} + return []Event{t.private(name, "Bet on 1, 2, 5, 10, 20, SWORD or SHIELD — an unambiguous prefix is fine. DOUBLE is automatic, not a bet.")}, false } spot = resolved if amount < t.Config.MinBet || amount > t.Config.MaxBet { - return []Event{t.private(name, "Big 6 bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")} + return []Event{t.private(name, "Big 6 bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")}, false } // Keep the original aggregate representable for shared menus and refunds. if amount > int(^uint(0)>>1)-p.Wager.Total { - return []Event{t.private(name, "Your total stake is too large.")} + return []Event{t.private(name, "Your total stake is too large.")}, false } wager, err := w.Wager(amount) if err != nil { - return []Event{t.private(name, walletErrorMessage(err))} + return []Event{t.private(name, walletErrorMessage(err))}, false } p.bets = append(p.bets, bigSixBet{spot, wager}) p.HasBet = true @@ -281,7 +290,34 @@ func (t *bigSixTable) bet(name, spot string, amount int, w Wallet) []Event { p.Wager.Total += amount p.lastSpot, p.lastBet, p.SittingOut = spot, amount, false events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d on %s.", name, amount, spot), fmt.Sprintf("You bet %d on %s.", amount, spot))} - return append(events, t.openRound()...) + return append(events, t.openRound()...), true +} + +// rebetAll repeats every remembered wager from the previous round in one +// command, so a bare bet/rebet restores a whole spread. +func (t *bigSixTable) rebetAll(name string, w Wallet) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.players.get(name) + if p == nil { + return []Event{t.private(name, "You aren't playing Big 6.")} + } + if t.phase == phasePlaying { + return []Event{t.private(name, "No more bets! The wheel is spinning.")} + } + if len(p.lastBets) == 0 { + return []Event{t.private(name, "Bet on 1, 2, 5, 10, 20, SWORD or SHIELD — an unambiguous prefix is fine. DOUBLE is automatic, not a bet.")} + } + var events []Event + for _, b := range p.lastBets { + ev, ok := t.betLocked(name, b.spot, b.amount, w) + events = append(events, ev...) + if !ok { + // A rejected stake ends the replay rather than partially betting. + break + } + } + return events } // Motion is planned backwards from an independently sampled uniform outcome. @@ -452,6 +488,10 @@ func (t *bigSixTable) reset() { t.frames = nil t.multiplier.SetInt64(1) for _, p := range t.players.ordered() { + p.lastBets = p.lastBets[:0] + for _, b := range p.bets { + p.lastBets = append(p.lastBets, rebetWager{spot: b.spot, amount: b.wager.Total}) + } p.bets = nil p.HasBet = false p.Wager = Wager{} diff --git a/internal/casino/command_hints_test.go b/internal/casino/command_hints_test.go index b391526..32bdbfa 100644 --- a/internal/casino/command_hints_test.go +++ b/internal/casino/command_hints_test.go @@ -134,7 +134,7 @@ func TestOtherCasinoHintsRespectCommitment(t *testing.T) { t.Fatal("roulette spinning bets advertised") } r.phase = phaseWaiting - r.players.get("a").HasBet = false + r.resetRound() if !hintHas(r.menuEvent("a", true).Menu.Commands, "bet / rebet") { t.Fatal("roulette repeat missing") } diff --git a/internal/casino/core.go b/internal/casino/core.go index 3ba9765..ed56a97 100644 --- a/internal/casino/core.go +++ b/internal/casino/core.go @@ -199,6 +199,9 @@ func gameTitle(game GameName) string { if game == GameBigSix { return "Big 6" } + if game == GameCrazyTime { + return "Crazy Time" + } name := string(game) if name == "" { return "Table" diff --git a/internal/casino/craps.go b/internal/casino/craps.go index 208799e..2b0243b 100644 --- a/internal/casino/craps.go +++ b/internal/casino/craps.go @@ -276,7 +276,7 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event { if amount != requested { msg += fmt.Sprintf(" {casino_emphasis}Rounded up from %d for exact odds units.{/}", requested) } - return []Event{t.private(name, msg), t.betSummary(p), t.reminder(name)} + return stripMenuBalance([]Event{t.private(name, msg), t.betSummary(p), t.reminder(name)}) } return reject("Place an established line/come bet before taking odds.") } @@ -331,7 +331,7 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event { } events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d on %s.", name, amount, spot), private)} events = append(events, t.syncRoundClock()...) - return append(events, t.betSummary(p), t.reminder(name)) + return stripMenuBalance(append(events, t.betSummary(p), t.reminder(name))) } func (t *crapsTable) pay(p *crapsPlayer, b *crapsBet, profit, den int, oddsProfit int, refundOdds bool) bool { payout := crapsReturn(b, profit, den, oddsProfit, refundOdds) diff --git a/internal/casino/craps_test.go b/internal/casino/craps_test.go index 622e541..d453e06 100644 --- a/internal/casino/craps_test.go +++ b/internal/casino/craps_test.go @@ -422,3 +422,17 @@ func TestCrapsFailureRecoveryAndSnapshots(t *testing.T) { t.Fatal("unbet stranded queued departure") } } + +func TestCrapsBetOmitsWallet(t *testing.T) { + tb := crapsFixture() + tb.join("alice") + w := &testWallet{chips: 1000} + for _, e := range tb.bet("alice", "pass", 10, w) { + if e.ShowBalance || (e.Menu != nil && e.Menu.ShowBalance) { + t.Fatalf("craps bet printed the wallet: %+v", e) + } + } + if e := tb.reminder("alice"); e.Menu == nil || !e.Menu.ShowBalance { + t.Fatal("live craps state should still show the wallet") + } +} diff --git a/internal/casino/crazy_time.go b/internal/casino/crazy_time.go new file mode 100644 index 0000000..72b04c3 --- /dev/null +++ b/internal/casino/crazy_time.go @@ -0,0 +1,1440 @@ +package casino + +import ( + "fmt" + "math/big" + "math/rand" + "strings" + "sync" +) + +// Crazy Time is a single-seat betting wheel modelled on Evolution's game of +// the same name. The main wheel reuses Big 6's physical-wheel structure and +// animation; it can stop on a number (1, 2, 5 or 10) or on one of four bonus +// games (Coin Flip, Cash Hunt, Pachinko and the Crazy Time wheel). Every +// bonus is a pure multiplier generator: it only ever returns a positive +// multiple of the bonus stake, because the house edge lives in the main +// wheel's probabilities, not in the bonuses. +// +// RTP is exactly 98% for every betting target. The main wheel alone would +// return far less, so each round also spins a "Top Slot": it picks one of the +// eight symbols uniformly and a multiplier from that symbol's own +// distribution. If the main wheel then stops on that symbol, the win is +// multiplied. Each symbol's multiplier distribution is tuned so that the +// combined return is exactly 98%. +const ( + ctBonusCoinFlip = "coin_flip" + ctBonusCashHunt = "cash_hunt" + ctBonusPachinko = "pachinko" + ctBonusWheel = "crazy_time" + + // ctAll is the convenience target that places one wager on each of the + // four minigames. + ctAll = "ALL" + + // Exported bonus identifiers for the presentation layer. + CrazyTimeBonusCoinFlip = ctBonusCoinFlip + CrazyTimeBonusCashHunt = ctBonusCashHunt + CrazyTimeBonusPachinko = ctBonusPachinko + CrazyTimeBonusWheel = ctBonusWheel + + crazyTimeRTPTarget = 0.98 + crazyTimePockets = 54 +) + +// crazyTimeSpot is one main-wheel symbol and its betting payouts. Pays is the +// profit odds for the number symbols (1:1 for "1", 2:1 for "2", ...); Bonus +// names the bonus game for the four bonus symbols. +type crazyTimeSpot struct { + Label string + Count int + Pays int + Bonus string +} + +func crazyTimeSpots() []crazyTimeSpot { + return []crazyTimeSpot{ + {"1", 21, 1, ""}, + {"2", 13, 2, ""}, + {"5", 7, 5, ""}, + {"10", 4, 10, ""}, + {"COIN FLIP", 4, 0, ctBonusCoinFlip}, + {"CASH HUNT", 2, 0, ctBonusCashHunt}, + {"PACHINKO", 2, 0, ctBonusPachinko}, + {"CRAZY TIME", 1, 0, ctBonusWheel}, + } +} + +// crazyTimeLabels lists the eight betting symbols in wheel display order. +func crazyTimeLabels() []string { + spots := crazyTimeSpots() + labels := make([]string, len(spots)) + for i, s := range spots { + labels[i] = s.Label + } + return labels +} + +// crazyTimeWheel is the fixed, interleaved 54-pocket physical wheel. It is +// never shuffled during a round; a spin picks a pocket index uniformly. +var crazyTimeWheel = func() []string { + var wheel []string + for _, spot := range crazyTimeSpots() { + for i := 0; i < spot.Count; i++ { + wheel = append(wheel, spot.Label) + } + } + rand.New(rand.NewSource(603)).Shuffle(len(wheel), func(i, j int) { wheel[i], wheel[j] = wheel[j], wheel[i] }) + return wheel +}() + +// crazyTimeResolveSpot maps a written target to a betting symbol. An exact +// label always wins over a longer label that shares its prefix, so "1" is the +// $1 symbol and never a prefix of "10". Otherwise an unambiguous prefix is +// accepted, so "co" names COIN FLIP, "ca" CASH HUNT, "cr" CRAZY TIME and "p" +// PACHINKO. It reports false when nothing or more than one symbol matches. +func crazyTimeResolveSpot(s string) (string, bool) { + t := strings.ToUpper(strings.TrimSpace(s)) + t = strings.ReplaceAll(t, "_", " ") + t = strings.ReplaceAll(t, "-", " ") + t = strings.Join(strings.Fields(t), " ") + if t == "" { + return "", false + } + compact := strings.ReplaceAll(t, " ", "") + match := "" + for _, spot := range crazyTimeSpots() { + label := spot.Label + clabel := strings.ReplaceAll(label, " ", "") + if label == t || clabel == compact { + return label, true + } + } + for _, spot := range crazyTimeSpots() { + label := spot.Label + clabel := strings.ReplaceAll(label, " ", "") + if strings.HasPrefix(label, t) || strings.HasPrefix(clabel, compact) { + if match != "" { + return "", false + } + match = label + } + } + return match, match != "" +} + +// crazyTimeIsAll reports whether a written target asks for the convenience +// ALL bet (one wager on each minigame). +func crazyTimeIsAll(s string) bool { + t := strings.ToUpper(strings.TrimSpace(s)) + t = strings.ReplaceAll(t, "_", " ") + t = strings.ReplaceAll(t, "-", " ") + return strings.Join(strings.Fields(t), " ") == ctAll +} + +// ----- Bonus probability tables ----- + +// Coin Flip picks one of two weighted prize distributions and flips a fair +// coin for it. Low averages 3.265625 and high 15.296875 over a 128-ticket +// wheel each, so the bonus averages 9.28125. +var ( + ctCoinLowValues = []int{2, 3, 4, 5} + ctCoinLowWeights = []int{37, 43, 25, 23} + ctCoinHighValues = []int{7, 8, 9, 10, 12, 15, 20, 25, 50, 100} + ctCoinHighWeights = []int{16, 17, 14, 15, 17, 18, 13, 10, 7, 1} +) + +// Cash Hunt is 108 tiles, each carrying a positive multiplier. Weights are +// tickets out of 108; the board averages about 21.85. +const ctHuntSquares = 108 + +var ( + ctHuntValues = []int{2, 5, 10, 15, 25, 50, 100, 500} + ctHuntWeights = []int{30, 25, 20, 15, 8, 5, 3, 2} +) + +// Pachinko is sixteen bottom slots: fifteen fixed prizes drawn from the +// weighted table and one guaranteed DOUBLE. A drop lands uniformly, so the +// doubling chain has expected value sum(fixed)/14. +const ctPachinkoSlots = 16 + +var ( + ctPachinkoValues = []int{2, 5, 7, 10, 15, 20, 25, 50, 100} + ctPachinkoWeights = []int{1, 2, 2, 2, 2, 2, 2, 1, 1} +) + +// The Crazy Time wheel is 64 stops built from the observed Wheel 1: twelve +// 10s, thirteen 15s, seven 20s, eight 25s, six 50s, two 100s and sixteen +// DOUBLEs. A DOUBLE doubles every fixed prize and respins, so its expected +// value is (sum fixed / 64) / (1 - 16/32). +const ( + ctBonusWheelSize = 64 + ctBonusDoubleCount = 16 +) + +var ( + ctBonusWheelValues = []int{10, 15, 20, 25, 50, 100} + ctBonusWheelCounts = []int{12, 13, 7, 8, 6, 2} +) + +func ctWeightedMean(values, weights []int) float64 { + sum, total := 0, 0 + for i := range values { + sum += values[i] * weights[i] + total += weights[i] + } + if total == 0 { + return 0 + } + return float64(sum) / float64(total) +} + +func ctCoinFlipMean() float64 { + return (ctWeightedMean(ctCoinLowValues, ctCoinLowWeights) + ctWeightedMean(ctCoinHighValues, ctCoinHighWeights)) / 2 +} + +func ctCashHuntMean() float64 { return ctWeightedMean(ctHuntValues, ctHuntWeights) } + +func ctPachinkoMean() float64 { + // Fifteen fixed prizes plus one DOUBLE: V = sum(fixed)/16 + (1/16)*2V. + return 15 * ctWeightedMean(ctPachinkoValues, ctPachinkoWeights) / 14 +} + +func ctBonusWheelMean() float64 { + sum := 0 + for i := range ctBonusWheelValues { + sum += ctBonusWheelValues[i] * ctBonusWheelCounts[i] + } + return (float64(sum) / ctBonusWheelSize) / (1 - 2*float64(ctBonusDoubleCount)/ctBonusWheelSize) +} + +func crazyTimeBonusMean(kind string) float64 { + switch kind { + case ctBonusCoinFlip: + return ctCoinFlipMean() + case ctBonusCashHunt: + return ctCashHuntMean() + case ctBonusPachinko: + return ctPachinkoMean() + case ctBonusWheel: + return ctBonusWheelMean() + } + return 0 +} + +// crazyTimeBaseRTP is the return of a single wager before the Top Slot +// multiplier: the probability the wheel lands on the target times the +// target's total-return multiplier. +func crazyTimeBaseRTP(spot string) float64 { + for _, s := range crazyTimeSpots() { + if s.Label != spot { + continue + } + if s.Pays > 0 { + return float64(s.Count) * float64(s.Pays+1) / crazyTimePockets + } + return float64(s.Count) / crazyTimePockets * crazyTimeBonusMean(s.Bonus) + } + return 0 +} + +// crazyTimeWinMultiplier is the total-return multiplier of a winning number +// bet, used by the house-edge reference and settlement. +func crazyTimeWinMultiplier(spot string, bonusValue int) int { + for _, s := range crazyTimeSpots() { + if s.Label != spot { + continue + } + if s.Pays > 0 { + return s.Pays + 1 + } + return bonusValue + } + return 0 +} + +// ----- Top Slot ----- + +// ctTopDist is a weighted integer multiplier distribution: mostly 1x misses +// with occasional larger multipliers. +type ctTopDist struct { + values []int + weights []float64 + total float64 +} + +// ctBuildTopDist builds a multiplier distribution whose mean is exactly +// target. A fixed base distribution over 2..50 is used, and the weight of a +// 1x multiplier is solved so the mean lands on target. +func ctBuildTopDist(target float64) ctTopDist { + baseValues := []int{2, 3, 4, 5, 7, 10, 15, 20, 25, 50} + baseWeights := []float64{10, 8, 8, 6, 5, 4, 2, 2, 1, 1} + sum, weight := 0.0, 0.0 + for i := range baseValues { + sum += float64(baseValues[i]) * baseWeights[i] + weight += baseWeights[i] + } + if target <= 1 { + return ctTopDist{values: []int{1}, weights: []float64{1}, total: 1} + } + ones := (sum - target*weight) / (target - 1) + if ones < 0 { + ones = 0 + } + values := append([]int{1}, baseValues...) + weights := append([]float64{ones}, baseWeights...) + total := ones + weight + return ctTopDist{values: values, weights: weights, total: total} +} + +func (d ctTopDist) draw(rng *rand.Rand) int { + if d.total <= 0 { + return 1 + } + x := rng.Float64() * d.total + for i, w := range d.weights { + x -= w + if x < 0 { + return d.values[i] + } + } + return d.values[len(d.values)-1] +} + +// crazyTimeTopTarget is the mean multiplier a symbol's Top Slot distribution +// must have for its combined return to be exactly crazyTimeRTPTarget. The Top +// Slot symbol is uniform, so only one eighth of the time does it boost a win. +func crazyTimeTopTarget(spot string) float64 { + base := crazyTimeBaseRTP(spot) + if base <= 0 { + return 1 + } + return 1 + 8*(crazyTimeRTPTarget/base-1) +} + +var crazyTimeTopDists = func() map[string]ctTopDist { + dists := make(map[string]ctTopDist, len(crazyTimeSpots())) + for _, s := range crazyTimeSpots() { + dists[s.Label] = ctBuildTopDist(crazyTimeTopTarget(s.Label)) + } + return dists +}() + +// crazyTimeTopSlotSymbol picks the Top Slot symbol uniformly from the eight +// betting targets. +func crazyTimeTopSlotSymbol(rng *rand.Rand) string { + labels := crazyTimeLabels() + return labels[rng.Intn(len(labels))] +} + +// ----- Snapshot types ----- + +// CrazyTimeBonusView carries the live state of whatever bonus game is running. +// One struct covers all four bonuses; Kind selects which fields the renderer +// reads. +type CrazyTimeBonusView struct { + Kind string + Phase string + + // Coin Flip. + CoinLow int + CoinHigh int + CoinSide string + CoinValue int + CoinFlipAlt bool + CoinResult bool + + // Cash Hunt. + HuntPreview []int + HuntFinal []int + HuntPick int + + // Pachinko. + PachoPockets []int + PachoRow int + PachoLane int + PachoDoubling int + PachoTop bool + + // Crazy Time wheel. + CTWheel []int + CTOffset int + CTPick int + CTDoubling int + CTResult bool + CTValue int +} + +// CrazyTimeBetView is one live wager shown by status/look. +type CrazyTimeBetView struct { + Spot string + Amount int +} + +// CrazyTimeSpotView is one paytable row. +type CrazyTimeSpotView struct { + Label string + Payout string + Bonus bool +} + +type CrazyTimeSnapshot struct { + Phase string + Pockets []string + TopSymbol string + TopMult int + Multiplier int + Wager int + Bets []CrazyTimeBetView + Bonus *CrazyTimeBonusView + Spots []CrazyTimeSpotView + RTP float64 +} + +func (*CrazyTimeSnapshot) snapshot() {} + +// ----- Engine ----- + +const ( + ctIdle = "idle" + ctSpinning = "spinning" + ctBonusing = "bonus" + ctPending = "pending" +) + +// ctPrint is one already-planned bonus animation frame: a structured view, +// a plain message, or both. +type ctPrint struct { + delay int + msg string + view *CrazyTimeBonusView + last bool +} + +type crazyTimeBet struct { + spot string + wager Wager +} + +type crazyTimeTable struct { + *tableBase + mu sync.Mutex + rng *rand.Rand + name string + bets []crazyTimeBet + lastSpot string + lastAmount int + // lastBets remembers the most recent stake on each symbol so a bare bet or + // rebet can restore a whole spread at once. + lastBets map[string]int + wallet Wallet + phase string + + // Top Slot. + topSymbol string + topMult int + + // Main wheel. + winIndex int + winSymbol string + position int + frames []bigSixFrame + frame int + wait int + + // Bonus. + bonusKind string + bonusValue int + prints []ctPrint + print int + printWait int +} + +func newCrazyTimeTable(room int, c TableConfig, rng *rand.Rand) *crazyTimeTable { + return &crazyTimeTable{tableBase: &tableBase{RoomID: room, Config: c}, rng: rng, phase: ctIdle} +} + +func validateCrazyTime(c TableConfig) error { + if c.MaxPlayers > 1 { + return fmt.Errorf("crazy time has one seat") + } + return nil +} + +func crazyTimeRules(TableConfig) string { + return fmt.Sprintf("Crazy Time: a 54-pocket wheel. Bet on 1 (1:1), 2 (2:1), 5 (5:1), 10 (10:1) or one of the four bonuses: COIN FLIP, CASH HUNT, PACHINKO or CRAZY TIME.\n"+ + "Place any number of wagers, then spin. A Top Slot picks one of the eight symbols and a multiplier; if the wheel stops on that same symbol, your win is multiplied.\n"+ + "Bonuses are always pure multipliers: COIN FLIP (fair coin, 2-100x), CASH HUNT (108 positive tiles, up to 500x), PACHINKO (positive prizes with chained DOUBLEs) and CRAZY TIME (a 64-stop three-pointer wheel with DOUBLE respins).\n"+ + "Every target returns exactly %.0f%% including the Top Slot; there is no house edge inside any bonus.\n"+ + "One seat only. Bets are committed at spin. Shutdown refunds an unsettled round; a failed payout keeps its stakes pending, and settle retries the same outcome.", + 100*crazyTimeRTPTarget) +} + +func crazyTimeHouseEdge(_ TableConfig, bet string) HouseEdge { + if bet == "" || bet == "main" || crazyTimeIsAll(bet) { + return edge(1 - crazyTimeRTPTarget) + } + spot, ok := crazyTimeResolveSpot(bet) + if !ok { + return unknownEdge("unrecognized Crazy Time target") + } + return edge(1 - crazyTimeRTPTargetFor(spot)) +} + +// crazyTimeRTPTargetFor returns the exact combined return of a target, +// including the Top Slot and the target's bonus multiplier distribution. +func crazyTimeRTPTargetFor(spot string) float64 { + base := crazyTimeBaseRTP(spot) + dist, ok := crazyTimeTopDists[spot] + if !ok { + return base + } + mean := 1.0 + if dist.total > 0 { + mean = 0 + for i, w := range dist.weights { + mean += float64(dist.values[i]) * w + } + mean /= dist.total + } + return base * (1 + (mean-1)/8) +} + +// ----- tableGame plumbing ----- + +func (t *crazyTimeTable) base() *tableBase { return t.tableBase } + +func (t *crazyTimeTable) idle() bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.name == "" +} + +func (t *crazyTimeTable) hasParticipant(n string) bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.name != "" && t.name == n +} + +func (t *crazyTimeTable) busy(n string) bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.name == n && (t.phase == ctSpinning || t.phase == ctBonusing) +} + +func (t *crazyTimeTable) overview() string { return "Crazy Time / 54-pocket bonus wheel" } + +func (t *crazyTimeTable) seats() []string { + t.mu.Lock() + defer t.mu.Unlock() + if t.name == "" { + return nil + } + return []string{t.name} +} + +func (t *crazyTimeTable) state() string { + t.mu.Lock() + defer t.mu.Unlock() + switch t.phase { + case ctSpinning: + return "spinning" + case ctBonusing: + return "bonus: " + t.bonusKind + case ctPending: + return "payout pending" + default: + return "waiting" + } +} + +func (t *crazyTimeTable) wagers(string) []WagerRow { + var rows []WagerRow + for _, s := range crazyTimeSpots() { + if s.Pays > 0 { + rows = append(rows, WagerRow{Bet: s.Label, Payout: fmt.Sprintf("%d:1", s.Pays), Win: "Wheel stops on " + s.Label}) + continue + } + rows = append(rows, WagerRow{Bet: s.Label, Payout: "varies", Win: "Wheel stops on " + s.Label + "; a positive bonus multiplier"}) + } + rows = append(rows, WagerRow{Bet: ctAll, Payout: "varies", Win: "Wheel stops on any minigame; plays that bonus"}) + return rows +} + +func (t *crazyTimeTable) hintsLocked() []MenuCommand { + if t.phase == ctSpinning { + return []MenuCommand{ + {Command: "table", Desc: "Show the wheel"}, + {Command: "rules", Desc: "Crazy Time rules"}, + } + } + if t.phase == ctBonusing { + return []MenuCommand{ + {Command: "table", Desc: "Show the bonus board"}, + {Command: "rules", Desc: "Crazy Time rules"}, + } + } + if t.phase == ctPending { + return []MenuCommand{ + {Command: "settle", Desc: "Retry the pending payout"}, + {Command: "rules", Desc: "Crazy Time rules"}, + } + } + c := []MenuCommand{ + {Command: "bet N on BET", Desc: "Wager on 1, 2, 5, 10, ALL or a bonus"}, + } + if len(t.lastBets) > 0 { + c = append(c, MenuCommand{Command: "bet / rebet", Desc: "Repeat all your last wagers"}) + } + if len(t.bets) > 0 { + c = append(c, MenuCommand{Command: "unbet", Desc: "Take back all wagers"}) + } + c = append(c, MenuCommand{Command: "spin", Desc: "Start the wheel"}) + c = append(c, MenuCommand{Command: "table", Desc: "Show the felt"}, MenuCommand{Command: "stop", Desc: "Leave after your bets settle"}) + return withTableHints(c, t.wagers("")) +} + +func (t *crazyTimeTable) hints(n string) []MenuCommand { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + return t.hintsLocked() +} + +func (t *crazyTimeTable) viewLocked(phase string) *CrazyTimeSnapshot { + s := &CrazyTimeSnapshot{ + Phase: phase, + TopSymbol: t.topSymbol, + TopMult: t.topMult, + Wager: t.wagerTotalLocked(), + Multiplier: t.topMult, + RTP: 100 * crazyTimeRTPTarget, + } + for _, b := range t.bets { + s.Bets = append(s.Bets, CrazyTimeBetView{Spot: b.spot, Amount: b.wager.Total}) + } + if phase == "paytable" { + for _, s2 := range crazyTimeSpots() { + if s2.Pays > 0 { + s.Spots = append(s.Spots, CrazyTimeSpotView{Label: s2.Label, Payout: fmt.Sprintf("%d:1", s2.Pays)}) + } else { + s.Spots = append(s.Spots, CrazyTimeSpotView{Label: s2.Label, Payout: "bonus", Bonus: true}) + } + } + } + return s +} + +func (t *crazyTimeTable) wagerTotalLocked() int { + total := 0 + for _, b := range t.bets { + total += b.wager.Total + } + return total +} + +// stripLocked returns the wheel pockets around the current position, centred +// on the position when a result is being shown. +func (t *crazyTimeTable) stripLocked(result bool) []string { + count := 11 + if result { + count = 9 + } + pockets := make([]string, 0, count) + for i := 0; i < count; i++ { + pockets = append(pockets, crazyTimeWheel[(t.position+i-count/2+len(crazyTimeWheel))%len(crazyTimeWheel)]) + } + return pockets +} + +func (t *crazyTimeTable) snapshotLocked(phase string, result bool) *CrazyTimeSnapshot { + s := t.viewLocked(phase) + s.Pockets = t.stripLocked(result) + return s +} + +// board is the table command's reference view: the full paytable plus the +// live wheel only while one is in flight. +func (t *crazyTimeTable) board(string) Snapshot { + t.mu.Lock() + defer t.mu.Unlock() + if t.phase == ctSpinning { + return t.snapshotLocked(ctSpinning, false) + } + if t.phase == ctBonusing && len(t.prints) > 0 { + i := t.print + if i >= len(t.prints) { + i = len(t.prints) - 1 + } + return t.printSnapshotLocked(t.prints[i]) + } + return t.viewLocked("paytable") +} + +func (t *crazyTimeTable) statusLocked(n string) []Event { + phase := t.phase + if phase == "" { + phase = ctIdle + } + e := t.private(n, "") + e.Color = "casino_menu" + if t.phase == ctSpinning { + e.Snapshot = t.snapshotLocked(ctSpinning, false) + } else if t.phase == ctBonusing && len(t.prints) > 0 { + i := t.print + if i >= len(t.prints) { + i = len(t.prints) - 1 + } + e.Snapshot = t.printSnapshotLocked(t.prints[i]) + } else { + e.Snapshot = t.viewLocked("paytable") + } + if n == t.name { + e.Menu = &MenuView{ShowBalance: true, Commands: t.hintsLocked()} + } + return []Event{e} +} + +func (t *crazyTimeTable) status(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + return t.statusLocked(n) +} + +func (t *crazyTimeTable) join(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if t.name != "" { + return []Event{t.private(n, "Crazy Time is occupied.")} + } + t.resetLocked() + t.name = n + e := t.private(n, "") + e.Type, e.Player = EventJoined, n + return append([]Event{e}, t.statusLocked(n)...) +} + +func (t *crazyTimeTable) resetLocked() { + t.bets = nil + t.wallet = nil + t.phase = ctIdle + t.topSymbol, t.topMult = "", 0 + t.winIndex, t.winSymbol, t.position = 0, "", 0 + t.frames, t.frame, t.wait = nil, 0, 0 + t.bonusKind, t.bonusValue = "", 0 + t.prints, t.print, t.printWait = nil, 0, 0 +} + +func (t *crazyTimeTable) clearLocked() { + t.name = "" + t.lastSpot, t.lastAmount = "", 0 + t.lastBets = nil + t.resetLocked() +} + +func (t *crazyTimeTable) leave(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + if t.phase == ctSpinning || t.phase == ctBonusing { + return []Event{t.private(n, "Your Crazy Time wagers are committed until the round ends.")} + } + if t.phase == ctPending { + return []Event{t.private(n, "A payout is still pending. Use settle before leaving.")} + } + t.clearLocked() + e := t.playerAction(n, n+" leaves Crazy Time.", "You leave Crazy Time.") + e.Type, e.Player = EventLeft, n + return []Event{e} +} + +func (t *crazyTimeTable) rebet(n string) (string, int) { + t.mu.Lock() + defer t.mu.Unlock() + if n == t.name && t.phase == ctIdle && t.lastAmount > 0 { + return t.lastSpot, t.lastAmount + } + return "", 0 +} + +// ----- Betting ----- + +func (t *crazyTimeTable) bet(n, spot string, amount int, w Wallet) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + if t.phase == ctSpinning || t.phase == ctBonusing { + return []Event{t.private(n, "No more bets! The wheel is spinning.")} + } + if t.phase == ctPending { + return []Event{t.private(n, "A payout is still pending. Use settle first.")} + } + if strings.TrimSpace(spot) == "" { + spot = t.lastSpot + } + if crazyTimeIsAll(spot) { + return t.betAllLocked(n, amount, w) + } + resolved, ok := crazyTimeResolveSpot(spot) + if !ok { + return []Event{t.private(n, "Bet on 1, 2, 5, 10, ALL, COIN FLIP, CASH HUNT, PACHINKO or CRAZY TIME — an unambiguous prefix is fine.")} + } + events, _ := t.placeBetLocked(n, resolved, amount, w) + return events +} + +// placeBetLocked commits one wager on an already-resolved symbol. It reports +// false when no wager was placed, so a multi-bet replay can stop on the first +// rejected stake. +func (t *crazyTimeTable) placeBetLocked(n, resolved string, amount int, w Wallet) ([]Event, bool) { + if amount < t.Config.MinBet || amount > t.Config.MaxBet { + return []Event{t.private(n, "Crazy Time bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")}, false + } + if amount > int(^uint(0)>>1)-t.wagerTotalLocked() { + return []Event{t.private(n, "Your total stake is too large.")}, false + } + if w == nil { + return []Event{t.private(n, "No wallet is available.")}, false + } + wager, err := w.Wager(amount) + if err != nil { + return []Event{t.private(n, walletErrorMessage(err, "Not enough chips and credits."))}, false + } + t.wallet = w + t.bets = append(t.bets, crazyTimeBet{spot: resolved, wager: wager}) + if t.lastBets == nil { + t.lastBets = map[string]int{} + } + t.lastBets[resolved] = amount + t.lastSpot, t.lastAmount = resolved, amount + return []Event{t.playerAction(n, fmt.Sprintf("%s bets %d on %s.", n, amount, resolved), fmt.Sprintf("You bet %d on %s.", amount, resolved))}, true +} + +// betAllLocked treats ALL as one wager on each of the four minigames, so a +// single command covers every bonus and each stake keeps its own 98% return. +func (t *crazyTimeTable) betAllLocked(n string, amount int, w Wallet) []Event { + if amount < t.Config.MinBet || amount > t.Config.MaxBet { + return []Event{t.private(n, "Crazy Time bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")} + } + if w == nil { + return []Event{t.private(n, "No wallet is available.")} + } + if amount > (int(^uint(0)>>1)-t.wagerTotalLocked())/4 { + return []Event{t.private(n, "Your total stake is too large.")} + } + var placed []crazyTimeBet + var ids []string + for _, s := range crazyTimeSpots() { + if s.Bonus == "" { + continue + } + wager, err := w.Wager(amount) + if err != nil { + if len(ids) > 0 { + _ = w.RefundStakes(ids...) + } + return []Event{t.private(n, walletErrorMessage(err, "Not enough chips and credits for the ALL bet."))} + } + placed = append(placed, crazyTimeBet{spot: s.Label, wager: wager}) + ids = append(ids, wager.ID) + } + t.wallet = w + t.bets = append(t.bets, placed...) + if t.lastBets == nil { + t.lastBets = map[string]int{} + } + for _, b := range placed { + t.lastBets[b.spot] = amount + } + t.lastSpot, t.lastAmount = ctAll, amount + return []Event{t.playerAction(n, fmt.Sprintf("%s bets %d on ALL.", n, amount), fmt.Sprintf("You bet %d on all four minigames.", amount))} +} + +// rebetAll repeats every remembered wager from the previous round in one +// command, so a bare bet/rebet restores a whole multi-symbol spread. +func (t *crazyTimeTable) rebetAll(n string, w Wallet) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return []Event{t.private(n, "You aren't playing Crazy Time.")} + } + if t.phase == ctSpinning || t.phase == ctBonusing { + return []Event{t.private(n, "No more bets! The wheel is spinning.")} + } + if t.phase == ctPending { + return []Event{t.private(n, "A payout is still pending. Use settle first.")} + } + var events []Event + placed := 0 + for _, s := range crazyTimeSpots() { + amount := t.lastBets[s.Label] + if amount <= 0 { + continue + } + ev, ok := t.placeBetLocked(n, s.Label, amount, w) + events = append(events, ev...) + if !ok { + return events + } + placed++ + } + if placed == 0 { + return []Event{t.private(n, "Bet on 1, 2, 5, 10, ALL, COIN FLIP, CASH HUNT, PACHINKO or CRAZY TIME.")} + } + return events +} + +func (t *crazyTimeTable) menuEventLocked(n string) Event { + e := t.private(n, "") + e.Color = "casino_menu" + e.Menu = &MenuView{ShowBalance: true, Commands: t.hintsLocked()} + return e +} + +func (t *crazyTimeTable) action(n, action string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + f := strings.Fields(strings.ToLower(strings.TrimSpace(action))) + if len(f) == 0 { + return t.statusLocked(n) + } + switch f[0] { + case "spin": + if t.phase == ctSpinning || t.phase == ctBonusing { + return []Event{t.private(n, "The wheel is already spinning.")} + } + if t.phase == ctPending { + return []Event{t.private(n, "Use settle to retry the pending payout.")} + } + if len(t.bets) == 0 { + return []Event{t.private(n, "Place your bets before spinning.")} + } + return t.startSpinLocked() + case "unbet": + if t.phase != ctIdle { + return []Event{t.private(n, "You cannot take back bets after the wheel starts.")} + } + if len(t.bets) == 0 { + return []Event{t.private(n, "Nothing to take back.")} + } + ids := make([]string, 0, len(t.bets)) + total := 0 + for _, b := range t.bets { + ids = append(ids, b.wager.ID) + total += b.wager.Total + } + if t.wallet != nil { + if err := t.wallet.RefundStakes(ids...); err != nil { + return []Event{t.private(n, payoutFailedMessage())} + } + } + t.bets = nil + return []Event{t.private(n, fmt.Sprintf("You take back %d credits.", total))} + case "settle": + if t.phase == ctPending { + return t.settleLocked() + } + return []Event{t.private(n, "There is no pending payout.")} + } + return []Event{t.private(n, "Place bets, then spin. Use rules for Crazy Time help.")} +} + +// ----- Main wheel animation ----- + +func (t *crazyTimeTable) startSpinLocked() []Event { + t.phase = ctSpinning + t.topSymbol = crazyTimeTopSlotSymbol(t.rng) + t.topMult = crazyTimeTopDists[t.topSymbol].draw(t.rng) + t.winIndex = t.rng.Intn(len(crazyTimeWheel)) + t.winSymbol = crazyTimeWheel[t.winIndex] + t.frames = bigSixAnimation() + t.frame = 0 + distance := 0 + for _, f := range t.frames { + distance += f.move + } + t.position = (t.winIndex - distance%len(crazyTimeWheel) + len(crazyTimeWheel)) % len(crazyTimeWheel) + t.wait = t.frames[0].delay + 1 + e := t.event("No more bets! The Crazy Time wheel begins to turn...", true) + e.Snapshot = t.snapshotLocked(ctSpinning, false) + return []Event{e} +} + +func (t *crazyTimeTable) tick() []Event { + t.mu.Lock() + defer t.mu.Unlock() + switch t.phase { + case ctSpinning: + return t.tickSpinLocked() + case ctBonusing: + return t.tickBonusLocked() + } + return nil +} + +func (t *crazyTimeTable) tickSpinLocked() []Event { + t.wait-- + if t.wait > 0 { + return nil + } + t.position = (t.position + t.frames[t.frame].move) % len(crazyTimeWheel) + t.frame++ + if t.frame == len(t.frames) { + return t.revealLocked() + } + t.wait = t.frames[t.frame].delay + e := t.event("", true) + e.Snapshot = t.snapshotLocked(ctSpinning, false) + return []Event{e} +} + +// revealLocked stops the main wheel. A number settles immediately; a bonus +// starts its own animation and settles on its final frame. +func (t *crazyTimeTable) revealLocked() []Event { + t.position = t.winIndex + topMatch := t.topSymbol == t.winSymbol + msg := "The pointer stops on {casino_emphasis}" + t.winSymbol + "{/}!" + if topMatch && t.topMult > 1 { + msg += fmt.Sprintf(" The Top Slot multiplies it by %d!", t.topMult) + } + e := t.event(msg, true) + e.Snapshot = t.snapshotLocked("result", true) + events := []Event{e} + if t.isBonusLocked() { + return append(events, t.beginBonusLocked()...) + } + return append(events, t.settleLocked()...) +} + +func (t *crazyTimeTable) isBonusLocked() bool { + return crazyTimeWinMultiplier(t.winSymbol, 0) == 0 +} + +func (t *crazyTimeTable) tickBonusLocked() []Event { + if t.printWait > 0 { + t.printWait-- + return nil + } + if t.print >= len(t.prints) { + return t.settleLocked() + } + p := t.prints[t.print] + t.print++ + e := t.private(t.name, p.msg) + if p.view != nil { + e.Snapshot = &CrazyTimeSnapshot{ + Phase: ctBonusing, + TopSymbol: t.topSymbol, + TopMult: t.topMult, + Multiplier: t.topMult, + Bonus: p.view, + } + } + if p.delay > 0 { + t.printWait = p.delay + } + if p.last { + return append([]Event{e}, t.settleLocked()...) + } + return []Event{e} +} + +func (t *crazyTimeTable) printSnapshotLocked(p ctPrint) *CrazyTimeSnapshot { + return &CrazyTimeSnapshot{ + Phase: ctBonusing, + TopSymbol: t.topSymbol, + TopMult: t.topMult, + Multiplier: t.topMult, + Bonus: p.view, + } +} + +// ----- Bonuses ----- + +func (t *crazyTimeTable) beginBonusLocked() []Event { + kind := "" + for _, s := range crazyTimeSpots() { + if s.Label == t.winSymbol { + kind = s.Bonus + } + } + t.bonusKind = kind + t.bonusValue = 0 + switch kind { + case ctBonusCoinFlip: + t.buildCoinFlipLocked() + case ctBonusCashHunt: + t.buildCashHuntLocked() + case ctBonusPachinko: + t.buildPachinkoLocked() + case ctBonusWheel: + t.buildBonusWheelLocked() + } + t.phase = ctBonusing + sym := t.winSymbol + announce := t.event("{casino_emphasis}"+sym+"{/} bonus! The game is played now.", true) + if len(t.prints) == 0 { + return []Event{announce} + } + first := t.prints[0] + t.print = 1 + if first.delay > 0 { + t.printWait = first.delay + } + events := []Event{announce, t.nextBonusEventLocked(first)} + if first.last { + events = append(events, t.settleLocked()...) + } + return events +} + +func (t *crazyTimeTable) nextBonusEventLocked(p ctPrint) Event { + e := t.private(t.name, p.msg) + if p.view != nil { + e.Snapshot = t.printSnapshotLocked(p) + } + return e +} + +func ctWeightedPick(rng *rand.Rand, values, weights []int) int { + total := 0 + for _, w := range weights { + total += w + } + ticket := rng.Intn(total) + for i, w := range weights { + if ticket < w { + return values[i] + } + ticket -= w + } + return values[len(values)-1] +} + +func (t *crazyTimeTable) buildCoinFlipLocked() { + low := ctWeightedPick(t.rng, ctCoinLowValues, ctCoinLowWeights) + high := ctWeightedPick(t.rng, ctCoinHighValues, ctCoinHighWeights) + side := "red" + value := low + if t.rng.Intn(2) == 0 { + side, value = "blue", high + } + t.bonusValue = value + view := &CrazyTimeBonusView{Kind: ctBonusCoinFlip, Phase: "faces", CoinLow: low, CoinHigh: high} + t.prints = append(t.prints, ctPrint{delay: 2, view: view}) + frames := 4 + t.rng.Intn(4) + for i := 0; i < frames; i++ { + alt := i%2 == 0 + t.prints = append(t.prints, ctPrint{delay: 1, view: &CrazyTimeBonusView{Kind: ctBonusCoinFlip, Phase: "flip", CoinLow: low, CoinHigh: high, CoinFlipAlt: alt}}) + } + t.prints = append(t.prints, ctPrint{delay: 3, view: &CrazyTimeBonusView{Kind: ctBonusCoinFlip, Phase: "result", CoinLow: low, CoinHigh: high, CoinSide: side, CoinValue: value, CoinResult: true}, last: true}) +} + +func (t *crazyTimeTable) buildCashHuntLocked() { + board := make([]int, ctHuntSquares) + for i := range board { + board[i] = ctWeightedPick(t.rng, ctHuntValues, ctHuntWeights) + } + preview := append([]int(nil), board...) + t.rng.Shuffle(len(board), func(i, j int) { board[i], board[j] = board[j], board[i] }) + pick := t.rng.Intn(ctHuntSquares) + t.bonusValue = board[pick] + t.prints = append(t.prints, + ctPrint{delay: 3, view: &CrazyTimeBonusView{Kind: ctBonusCashHunt, Phase: "preview", HuntPreview: preview}}, + ctPrint{delay: 2, view: &CrazyTimeBonusView{Kind: ctBonusCashHunt, Phase: "hidden"}}, + ) + for i := 0; i < 2+t.rng.Intn(2); i++ { + t.prints = append(t.prints, ctPrint{delay: 1, view: &CrazyTimeBonusView{Kind: ctBonusCashHunt, Phase: "shuffle", HuntFinal: board}}) + } + t.prints = append(t.prints, ctPrint{delay: 3, view: &CrazyTimeBonusView{Kind: ctBonusCashHunt, Phase: "result", HuntFinal: board, HuntPick: pick}, last: true}) +} + +// crazyPachinkoPath builds a plausible drop path that ends on landing. Each +// row is one lane; the path eases from a random start toward the landing. +func crazyPachinkoPath(rng *rand.Rand, landing int) []int { + start := rng.Intn(ctPachinkoSlots) + path := make([]int, ctPachinkoSlots) + for row := 0; row < ctPachinkoSlots; row++ { + frac := float64(row) / float64(ctPachinkoSlots-1) + lane := int(float64(start)*(1-frac) + float64(landing)*frac + 0.5) + if row > 0 && row < ctPachinkoSlots-1 { + lane += rng.Intn(3) - 1 + } + if lane < 0 { + lane = 0 + } + if lane >= ctPachinkoSlots { + lane = ctPachinkoSlots - 1 + } + path[row] = lane + } + path[ctPachinkoSlots-1] = landing + return path +} + +// newPachinkoBoard deals fifteen fixed prizes plus one DOUBLE, then shuffles. +func (t *crazyTimeTable) newPachinkoBoardLocked() []int { + board := make([]int, ctPachinkoSlots) + for i := 0; i < ctPachinkoSlots-1; i++ { + board[i] = ctWeightedPick(t.rng, ctPachinkoValues, ctPachinkoWeights) + } + board[ctPachinkoSlots-1] = -1 // DOUBLE + t.rng.Shuffle(len(board), func(i, j int) { board[i], board[j] = board[j], board[i] }) + return board +} + +func (t *crazyTimeTable) buildPachinkoLocked() { + board := t.newPachinkoBoardLocked() + doubling := 0 + for drops := 0; drops < 20; drops++ { + topCopy := append([]int(nil), board...) + t.prints = append(t.prints, ctPrint{delay: 2, view: &CrazyTimeBonusView{Kind: ctBonusPachinko, Phase: "top", PachoPockets: topCopy, PachoTop: true, PachoDoubling: doubling}}) + landing := t.rng.Intn(ctPachinkoSlots) + path := crazyPachinkoPath(t.rng, landing) + for row := 1; row < ctPachinkoSlots; row++ { + t.prints = append(t.prints, ctPrint{delay: 1, view: &CrazyTimeBonusView{Kind: ctBonusPachinko, Phase: "drop", PachoPockets: board, PachoRow: row, PachoLane: path[row], PachoDoubling: doubling}}) + } + if board[landing] == -1 { + doubling++ + for i := range board { + if board[i] > 0 { + board[i] *= 2 + } + } + t.prints = append(t.prints, ctPrint{delay: 2, msg: "DOUBLE! Every prize is doubled and the puck drops again.", view: &CrazyTimeBonusView{Kind: ctBonusPachinko, Phase: "top", PachoPockets: append([]int(nil), board...), PachoTop: true, PachoDoubling: doubling}}) + continue + } + t.bonusValue = board[landing] + t.prints = append(t.prints, ctPrint{delay: 3, view: &CrazyTimeBonusView{Kind: ctBonusPachinko, Phase: "result", PachoPockets: board, PachoRow: ctPachinkoSlots, PachoLane: landing, PachoDoubling: doubling}, last: true}) + return + } + // Safety valve: an astronomically unlikely 20 doubles. Pay the current board. + t.bonusValue = 1 + for _, v := range board { + if v > 0 { + t.bonusValue = v + break + } + } + t.prints = append(t.prints, ctPrint{delay: 1, view: &CrazyTimeBonusView{Kind: ctBonusPachinko, Phase: "result", PachoPockets: board, PachoRow: ctPachinkoSlots, PachoLane: 0, PachoDoubling: doubling}, last: true}) +} + +// ctBonusWheelStops builds the 64-stop wheel: -1 marks a DOUBLE, otherwise +// the value is a fixed prize. The caller shuffles it for layout variety. +func ctBonusWheelStops() []int { + var wheel []int + for i := range ctBonusWheelValues { + for n := 0; n < ctBonusWheelCounts[i]; n++ { + wheel = append(wheel, ctBonusWheelValues[i]) + } + } + for i := 0; i < ctBonusDoubleCount; i++ { + wheel = append(wheel, -1) + } + return wheel +} + +// CrazyBonusPointerCells are the three pointer columns (as cell index within +// the rendered strip). The engine and renderer share them so the chosen +// pointer always shows the winning stop. +var CrazyBonusPointerCells = [3]int{2, 5, 8} + +func (t *crazyTimeTable) buildBonusWheelLocked() { + wheel := ctBonusWheelStops() + t.rng.Shuffle(len(wheel), func(i, j int) { wheel[i], wheel[j] = wheel[j], wheel[i] }) + pick := t.rng.Intn(3) + doubling := 0 + for spins := 0; spins < 20; spins++ { + winIndex := t.rng.Intn(ctBonusWheelSize) + value := wheel[winIndex] + pcol := CrazyBonusPointerCells[pick] + finalOffset := ((winIndex-pcol)%ctBonusWheelSize + ctBonusWheelSize) % ctBonusWheelSize + t.appendBonusSpinLocked(wheel, finalOffset, pick, doubling, value == -1) + if value == -1 { + doubling++ + for i := range wheel { + if wheel[i] > 0 { + wheel[i] *= 2 + } + } + continue + } + t.bonusValue = value + t.prints = append(t.prints, ctPrint{delay: 3, view: &CrazyTimeBonusView{Kind: ctBonusWheel, Phase: "result", CTWheel: append([]int(nil), wheel...), CTOffset: finalOffset, CTPick: pick, CTDoubling: doubling, CTResult: true, CTValue: value}, last: true}) + return + } + t.bonusValue = 1 + for _, v := range wheel { + if v > 0 { + t.bonusValue = v + break + } + } + t.prints = append(t.prints, ctPrint{delay: 1, view: &CrazyTimeBonusView{Kind: ctBonusWheel, Phase: "result", CTWheel: wheel, CTOffset: 0, CTPick: pick, CTDoubling: doubling, CTResult: true, CTValue: t.bonusValue}, last: true}) +} + +// appendBonusSpinLocked appends the frames of one Crazy Time wheel spin, +// ending on finalOffset. The spin is deliberately longer than the main +// wheel's. +func (t *crazyTimeTable) appendBonusSpinLocked(wheel []int, finalOffset, pick, doubling int, double bool) { + steps := make([]int, 0, 24) + step := 5 + t.rng.Intn(4) + for n := 4 + t.rng.Intn(3); n > 0; n-- { + steps = append(steps, step) + if step > 2 && t.rng.Intn(2) == 0 { + step-- + } + } + step = min(step, 2+t.rng.Intn(2)) + for n := 2 + t.rng.Intn(3); n > 0; n-- { + steps = append(steps, step) + } + for i := 0; i < 5+t.rng.Intn(4); i++ { + steps = append(steps, 1) + } + if t.rng.Intn(2) == 0 { + steps = append(steps, 0) + } + sum := 0 + for _, s := range steps { + sum += s + } + for sum < ctBonusWheelSize*3 { + steps = append([]int{8}, steps...) + sum += 8 + } + offset := ((finalOffset-sum)%ctBonusWheelSize + ctBonusWheelSize) % ctBonusWheelSize + for _, s := range steps { + t.prints = append(t.prints, ctPrint{delay: 1, view: &CrazyTimeBonusView{Kind: ctBonusWheel, Phase: "spin", CTWheel: append([]int(nil), wheel...), CTOffset: offset, CTPick: pick, CTDoubling: doubling}}) + offset = (offset + s) % ctBonusWheelSize + } + label := "SPIN" + if double { + label = "DOUBLE — respin!" + } + t.prints = append(t.prints, ctPrint{delay: 2, msg: label, view: &CrazyTimeBonusView{Kind: ctBonusWheel, Phase: "spin", CTWheel: append([]int(nil), wheel...), CTOffset: finalOffset, CTPick: pick, CTDoubling: doubling}}) +} + +// ----- Settlement ----- + +// settleLocked pays every committed wager from the fixed outcome. It is +// idempotent: a failed bank save keeps the stakes and the outcome, so settle +// retries the same payout. +func (t *crazyTimeTable) settleLocked() []Event { + if t.wallet == nil || len(t.bets) == 0 { + t.resetLocked() + return nil + } + mult := 1 + if t.winSymbol == t.topSymbol && t.topMult > 0 { + mult = t.topMult + } + winMult := crazyTimeWinMultiplier(t.winSymbol, t.bonusValue) + total := new(big.Int) + representable := true + var ids []string + var lines []SettlementLine + for _, b := range t.bets { + ids = append(ids, b.wager.ID) + returned := new(big.Int) + result := "loss" + odds := "0:1" + if b.spot == t.winSymbol { + returned.SetInt64(int64(b.wager.Total)) + returned.Mul(returned, big.NewInt(int64(winMult))) + returned.Mul(returned, big.NewInt(int64(mult))) + result = "win" + odds = fmt.Sprintf("%d:1", winMult*mult-1) + } + total.Add(total, returned) + if !returned.IsInt64() || returned.Cmp(big.NewInt(int64(int(^uint(0)>>1)))) > 0 { + representable = false + } + lines = append(lines, SettlementLine{ + Label: b.spot, + Odds: odds, + Stake: b.wager.Total, + Returned: int(returned.Int64()), + Result: result, + }) + } + failed := !representable || total.Cmp(big.NewInt(MaxCredits)) > 0 + if !failed { + failed = t.wallet.PayCredits(int(total.Int64()), ids...) != nil + } + e := t.playerAction(t.name, t.name+" settles their Crazy Time wagers.", "") + if failed { + t.phase = ctPending + e.PrivateMessage = payoutFailedMessage() + " Use settle to retry the same outcome." + e.Message = t.name + "'s settlement is pending recovery." + e.ShowBalance = true + return []Event{e} + } + e.PrivateMessage = settlementText(lines) + e.Settlement = lines + e.Color = settlementTag(lines) + events := []Event{e} + t.resetLocked() + events = append(events, t.menuEventLocked(t.name)) + return events +} + +func (t *crazyTimeTable) markDisconnected(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + var events []Event + switch { + case t.phase == ctPending: + // Leave the stakes pending for recovery; the seat clears. + case t.phase == ctSpinning || t.phase == ctBonusing: + if t.wallet != nil { + ids := make([]string, 0, len(t.bets)) + for _, b := range t.bets { + ids = append(ids, b.wager.ID) + } + if len(ids) > 0 { + t.wallet.ResolveStakesByIDs(ids...) + } + } + default: + if t.wallet != nil { + _, _, _ = t.wallet.RefundOutstanding() + } + } + t.clearLocked() + e := t.event(n+" leaves Crazy Time.", true) + e.Type, e.Player = EventLeft, n + return append(events, e) +} + +func (t *crazyTimeTable) refundAll() []Event { + t.mu.Lock() + defer t.mu.Unlock() + var events []Event + if t.phase == ctPending { + // A decided outcome whose payout could not be persisted: retry it so + // shutdown pays the same result rather than refunding a decided round. + events = append(events, t.settleLocked()...) + } else if (t.phase == ctSpinning || t.phase == ctBonusing) && t.wallet != nil { + chips, credits, err := t.wallet.RefundOutstanding() + message := fmt.Sprintf("Crazy Time closes; %d credits/chips refunded.", chips+credits) + if err != nil { + message = "Crazy Time refund could not be persisted; your stake remains pending for recovery." + } + events = append(events, t.private(t.name, message)) + } + t.clearLocked() + return events +} diff --git a/internal/casino/crazy_time_test.go b/internal/casino/crazy_time_test.go new file mode 100644 index 0000000..d783cc1 --- /dev/null +++ b/internal/casino/crazy_time_test.go @@ -0,0 +1,323 @@ +package casino + +import ( + "math" + "math/rand" + "strings" + "testing" +) + +func newCrazyTimeTest() *crazyTimeTable { + return newCrazyTimeTable(1, TableConfig{ID: "crazy", Game: GameCrazyTime, MinBet: 1, MaxBet: 100000}, rand.New(rand.NewSource(99))) +} + +func TestCrazyTimeRegistrationAndRTP(t *testing.T) { + cfg := TableConfig{ID: "crazy", Game: GameCrazyTime} + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + if cfg.Normalize().MaxPlayers != 1 { + t.Fatal("crazy time must be single-seat") + } + if cfg.HelpTopic() != "crazy time" { + t.Fatalf("help topic %q", cfg.HelpTopic()) + } + if newTable(1, cfg, newTestRand()) == nil { + t.Fatal("registration failed") + } + for _, spot := range crazyTimeSpots() { + rtp := crazyTimeRTPTargetFor(spot.Label) + if math.Abs(rtp-crazyTimeRTPTarget) > 1e-9 { + t.Fatalf("%s RTP = %.12f, want %.12f", spot.Label, rtp, crazyTimeRTPTarget) + } + e := cfg.HouseEdge(spot.Label) + if len(e.Parts) != 1 || math.Abs(e.Parts[0].Loss-(1-crazyTimeRTPTarget)) > 1e-9 { + t.Fatalf("%s edge %+v", spot.Label, e) + } + dist := crazyTimeTopDists[spot.Label] + if dist.total <= 0 { + t.Fatalf("%s empty top dist", spot.Label) + } + for _, w := range dist.weights { + if w < 0 { + t.Fatalf("%s negative weight", spot.Label) + } + } + mean := 0.0 + for i, w := range dist.weights { + mean += float64(dist.values[i]) * w + } + mean /= dist.total + if math.Abs(mean-crazyTimeTopTarget(spot.Label)) > 1e-9 { + t.Fatalf("%s top mean %.9f target %.9f", spot.Label, mean, crazyTimeTopTarget(spot.Label)) + } + } +} + +func TestCrazyTimeWheelCountsAndPositiveBonuses(t *testing.T) { + counts := map[string]int{} + for _, s := range crazyTimeWheel { + counts[s]++ + } + if len(crazyTimeWheel) != crazyTimePockets { + t.Fatalf("wheel has %d pockets", len(crazyTimeWheel)) + } + total := 0 + for _, spot := range crazyTimeSpots() { + if counts[spot.Label] != spot.Count { + t.Fatalf("%s count %d want %d", spot.Label, counts[spot.Label], spot.Count) + } + total += spot.Count + } + if total != crazyTimePockets { + t.Fatalf("counts sum to %d", total) + } + // Every bonus distribution is strictly positive. + for _, v := range ctHuntValues { + if v < 1 { + t.Fatalf("cash hunt value %d is not positive", v) + } + } + for _, v := range ctPachinkoValues { + if v < 1 { + t.Fatalf("pachinko value %d is not positive", v) + } + } + for _, v := range ctCoinLowValues { + if v < 1 { + t.Fatalf("coin low value %d is not positive", v) + } + } + for _, v := range ctCoinHighValues { + if v < 1 { + t.Fatalf("coin high value %d is not positive", v) + } + } + for _, v := range ctBonusWheelValues { + if v < 1 { + t.Fatalf("crazy wheel value %d is not positive", v) + } + } +} + +func TestCrazyTimeBetSpinSettle(t *testing.T) { + table := newCrazyTimeTest() + w := &recordingWallet{testWallet: testWallet{credits: 1000}} + table.join("a") + table.bet("a", "1", 10, w) + table.bet("a", "crazy time", 5, w) + if len(table.bets) != 2 || len(w.open) != 2 { + t.Fatalf("bets not committed: %+v open=%d", table.bets, len(w.open)) + } + // Capture the fixed outcome before the wheel settles and resets it. + table.topSymbol, table.topMult = "2", 7 + events := table.action("a", "spin") + if table.phase != ctSpinning || len(events) == 0 { + t.Fatal("spin did not start") + } + win := table.winSymbol + topSymbol, topMult := table.topSymbol, table.topMult + if win == "" { + t.Fatal("no winning symbol") + } + // Drive the wheel to completion. + for i := 0; i < 400 && table.phase == ctSpinning; i++ { + table.tick() + } + // A bonus may still be animating; drive it out too. + for i := 0; i < 1000 && table.phase == ctBonusing; i++ { + table.tick() + } + mult := 1 + if topSymbol == win { + mult = topMult + } + want := 0 + if win == "1" { + want += 10 * 2 * mult + } + if win == "CRAZY TIME" { + want += 5 * table.bonusValue * mult + } + if w.paid != want { + t.Fatalf("paid %d want %d (win=%s top=%s x%d)", w.paid, want, win, topSymbol, topMult) + } + if len(w.open) != 0 { + t.Fatalf("stakes left open: %d", len(w.open)) + } +} + +func TestCrazyTimeAllBonusesOnlyPayPositive(t *testing.T) { + for _, kind := range []string{ctBonusCoinFlip, ctBonusCashHunt, ctBonusPachinko, ctBonusWheel} { + for seed := int64(0); seed < 40; seed++ { + table := newCrazyTimeTable(1, TableConfig{ID: "c", Game: GameCrazyTime, MinBet: 1, MaxBet: 100000}, rand.New(rand.NewSource(seed+1))) + table.name = "a" + table.phase = ctBonusing + table.winSymbol = labelForBonus(kind) + table.topSymbol, table.topMult = "10", 1 + switch kind { + case ctBonusCoinFlip: + table.buildCoinFlipLocked() + case ctBonusCashHunt: + table.buildCashHuntLocked() + case ctBonusPachinko: + table.buildPachinkoLocked() + case ctBonusWheel: + table.buildBonusWheelLocked() + } + if table.bonusValue < 1 { + t.Fatalf("%s produced %d", kind, table.bonusValue) + } + if len(table.prints) == 0 { + t.Fatalf("%s produced no frames", kind) + } + } + } +} + +func labelForBonus(kind string) string { + for _, s := range crazyTimeSpots() { + if s.Bonus == kind { + return s.Label + } + } + return "" +} + +func TestCrazyTimeUnbetRefunds(t *testing.T) { + table := newCrazyTimeTest() + w := &testWallet{credits: 100} + table.join("a") + table.bet("a", "5", 10, w) + if w.credits != 90 { + t.Fatalf("stake not debited: %d", w.credits) + } + table.action("a", "unbet") + if w.paid != 10 || len(w.open) != 0 { + t.Fatalf("unbet did not refund: paid=%d open=%d", w.paid, len(w.open)) + } +} + +func TestCrazyTimeAllBetAndRebet(t *testing.T) { + table := newCrazyTimeTest() + w := &testWallet{credits: 1000} + table.join("a") + + events := table.bet("a", "all", 10, w) + if len(table.bets) != 4 { + t.Fatalf("ALL placed %d wagers, want 4", len(table.bets)) + } + if w.credits != 960 || len(w.open) != 4 { + t.Fatalf("ALL debit: credits=%d open=%d", w.credits, len(w.open)) + } + for _, e := range events { + if e.Menu != nil { + t.Fatalf("bet returned a menu (wallet would print): %+v", e) + } + } + + // Settle on a losing number, then rebet every remembered minigame wager. + table.winSymbol = "10" + table.topSymbol, table.topMult = "1", 1 + table.settleLocked() + if len(table.bets) != 0 { + t.Fatal("settlement did not clear the round") + } + before := w.credits + events = table.rebetAll("a", w) + if len(table.bets) != 4 { + t.Fatalf("rebet placed %d wagers, want 4", len(table.bets)) + } + if w.credits != before-40 { + t.Fatalf("rebet debit: credits=%d want %d", w.credits, before-40) + } + for _, e := range events { + if e.Menu != nil { + t.Fatalf("rebet returned a menu: %+v", e) + } + } +} + +func TestCrazyTimeResolveSpot(t *testing.T) { + cases := map[string]string{ + "1": "1", "2": "2", "5": "5", "10": "10", + "co": "COIN FLIP", "coin flip": "COIN FLIP", "coinflip": "COIN FLIP", + "ca": "CASH HUNT", "cash hunt": "CASH HUNT", + "p": "PACHINKO", "pach": "PACHINKO", + "cr": "CRAZY TIME", "crazy time": "CRAZY TIME", + } + for in, want := range cases { + got, ok := crazyTimeResolveSpot(in) + if !ok || got != want { + t.Fatalf("%q -> %q,%v want %q", in, got, ok, want) + } + } + if _, ok := crazyTimeResolveSpot("c"); ok { + t.Fatal("ambiguous c should not resolve") + } +} + +func TestCrazyTimeRulesText(t *testing.T) { + rules := crazyTimeRules(TableConfig{}) + if !strings.Contains(rules, "98%") || strings.Contains(rules, "%%") { + t.Fatalf("rules text: %q", rules) + } + if !strings.Contains(rules, "CASH HUNT") || !strings.Contains(rules, "Top Slot") { + t.Fatalf("rules text missing content: %q", rules) + } +} + +// TestCrazyTimeSettlementMath pins the payout arithmetic: a winning target +// pays stake * total-return-multiplier * Top Slot multiplier, and a losing +// target returns nothing. +func TestCrazyTimeSettlementMath(t *testing.T) { + cases := []struct { + spot, win, topSym string + bonus, topMult int + stake, want int + }{ + {"1", "1", "1", 0, 5, 10, 10 * 2 * 5}, + {"1", "10", "1", 0, 5, 10, 0}, + {"CASH HUNT", "CASH HUNT", "CASH HUNT", 3, 4, 7, 7 * 3 * 4}, + {"CASH HUNT", "CASH HUNT", "COIN FLIP", 500, 9, 2, 2 * 500}, + {"CRAZY TIME", "CRAZY TIME", "CRAZY TIME", 64, 50, 1, 64 * 50}, + } + for _, tc := range cases { + table := newCrazyTimeTable(1, TableConfig{ID: "c", Game: GameCrazyTime, MinBet: 1, MaxBet: 100000}, rand.New(rand.NewSource(3))) + w := &testWallet{credits: MaxCredits / 2} + table.name = "a" + table.wallet = w + wager, err := w.Wager(tc.stake) + if err != nil { + t.Fatal(err) + } + table.bets = []crazyTimeBet{{spot: tc.spot, wager: wager}} + table.winSymbol = tc.win + table.bonusValue = tc.bonus + table.topSymbol, table.topMult = tc.topSym, tc.topMult + before := w.paid + table.settleLocked() + if got := w.paid - before; got != tc.want { + t.Fatalf("%s win=%s bonus=%d top=%s x%d stake=%d paid %d want %d", tc.spot, tc.win, tc.bonus, tc.topSym, tc.topMult, tc.stake, got, tc.want) + } + } +} + +func TestCrazyTimeSpinDelaysFirstMove(t *testing.T) { + table := newCrazyTimeTest() + w := &testWallet{credits: 1000} + table.join("a") + table.bet("a", "1", 10, w) + events := table.action("a", "spin") + if len(events) == 0 || events[0].Snapshot == nil { + t.Fatal("spin did not print the opening wheel") + } + // The spin command and the first engine tick share a cycle, so the opening + // position must survive that tick before the wheel advances. + if ev := table.tick(); len(ev) != 0 { + t.Fatalf("first tick advanced the wheel immediately: %+v", ev) + } + if ev := table.tick(); len(ev) == 0 { + t.Fatal("second tick did not advance the wheel") + } +} diff --git a/internal/casino/pai_gow.go b/internal/casino/pai_gow.go index 8a7ddf3..ad0693d 100644 --- a/internal/casino/pai_gow.go +++ b/internal/casino/pai_gow.go @@ -417,9 +417,9 @@ func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event { // The main wager is the commitment point: when every active player has // one, the cards come out immediately rather than waiting on the timer. if spot == "main" && t.allBetLocked() { - return append(events, t.dealLocked()...) + return stripMenuBalance(append(events, t.dealLocked()...)) } - return append(events, t.statusLocked(name, false)...) + return stripMenuBalance(append(events, t.statusLocked(name, false)...)) } func (t *paiGowTable) allReady() bool { found := false diff --git a/internal/casino/registry.go b/internal/casino/registry.go index e87b45a..9d6017d 100644 --- a/internal/casino/registry.go +++ b/internal/casino/registry.go @@ -135,6 +135,15 @@ func init() { ruleText: bigSixRules, helpTopic: func(TableConfig) string { return "big6" }, }, + GameCrazyTime: { + houseEdge: crazyTimeHouseEdge, + validate: validateCrazyTime, + normalize: func(c TableConfig) TableConfig { c.MaxPlayers = 1; return c }, + hasRuleBlock: func(TableConfig) bool { return false }, + new: func(room int, c TableConfig, rng *rand.Rand) tableGame { return newCrazyTimeTable(room, c, rng) }, + ruleText: crazyTimeRules, + helpTopic: func(TableConfig) string { return "crazy time" }, + }, GameRoulette: { houseEdge: rouletteHouseEdge, validate: func(c TableConfig) error { diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go index 71a0916..254a472 100644 --- a/internal/casino/roulette.go +++ b/internal/casino/roulette.go @@ -459,8 +459,15 @@ func rouletteBasket(nums []string, double bool) ([]string, int, bool) { return nil, 0, false } +type roulettePlacedBet struct { + spot string + wager Wager +} + type roulettePlayer struct { Participant + bets []roulettePlacedBet + lastBets []rebetWager lastSpot string lastBet int } @@ -519,8 +526,8 @@ func (t *rouletteTable) menuEvent(name string, board bool) Event { p := t.players.get(name) if t.phase != phasePlaying && p != nil && !p.HasBet { commands = append(commands, MenuCommand{Command: "bet N on BET", Desc: "Place one wager"}) - if p.lastBet > 0 { - commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat your previous wager"}) + if len(p.lastBets) > 0 { + commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat all your last wagers"}) } } if p != nil { @@ -564,15 +571,20 @@ func (t *rouletteTable) leave(name string) []Event { func (t *rouletteTable) bet(name, spot string, amount int, w Wallet) []Event { t.mu.Lock() defer t.mu.Unlock() + events, _ := t.betLocked(name, spot, amount, w) + return events +} + +// betLocked is the locked implementation of bet, shared with the multi-wager +// replay so a rebet holds the table lock across every stake. It reports false +// when no wager was placed. +func (t *rouletteTable) betLocked(name, spot string, amount int, w Wallet) ([]Event, bool) { p := t.players.get(name) if p == nil { - return []Event{t.private(name, "You aren't playing roulette.")} + return []Event{t.private(name, "You aren't playing roulette.")}, false } if t.phase == phasePlaying { - return []Event{t.private(name, "No more bets! The wheel is spinning.")} - } - if p.HasBet { - return []Event{t.private(name, "You have already bet this spin.")} + return []Event{t.private(name, "No more bets! The wheel is spinning.")}, false } if spot == "" { spot = p.lastSpot @@ -580,21 +592,24 @@ func (t *rouletteTable) bet(name, spot string, amount int, w Wallet) []Event { spot = normalizeRouletteSpot(spot) resolved, ok := rouletteWagerFor(spot, t.Config.Roulette.DoubleZero, t.Config.Roulette.Racetrack) if !ok { - return []Event{t.private(name, "Unknown roulette target. Use table to see all available bets.")} + return []Event{t.private(name, "Unknown roulette target. Use table to see all available bets.")}, false } if amount < t.Config.MinBet || amount > t.Config.MaxBet { - return []Event{t.private(name, "Roulette bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")} + return []Event{t.private(name, "Roulette bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")}, false } // Check every possible winning return before committing even an unlimited // wager: a call bet's components pay different multiples. if err := resolved.validate(amount); err != nil { - return []Event{t.private(name, "That wager's potential payout is too large.")} + return []Event{t.private(name, "That wager's potential payout is too large.")}, false } wager, err := w.Wager(amount) if err != nil { - return []Event{t.private(name, walletErrorMessage(err))} + return []Event{t.private(name, walletErrorMessage(err))}, false } - p.HasBet, p.Wager, p.Wallet, p.lastSpot, p.lastBet, p.SittingOut = true, wager, w, spot, amount, false + p.bets = append(p.bets, roulettePlacedBet{spot: spot, wager: wager}) + p.HasBet, p.Wallet, p.lastSpot, p.lastBet, p.SittingOut = true, w, spot, amount, false + p.Wager.Total += amount + p.Wager.ID = wager.ID events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d on %s.", name, amount, spot), fmt.Sprintf("You bet %d on %s.", amount, spot))} // With two or more players a timed betting window lets the table bet // together. A lone player has no time limit: once their wager lands, @@ -611,7 +626,7 @@ func (t *rouletteTable) bet(name, spot string, amount int, w Wallet) []Event { events = append(events, t.startSpin()) } } - return events + return events, true } func (t *rouletteTable) tick() []Event { t.mu.Lock() @@ -729,23 +744,39 @@ func (t *rouletteTable) settle() []Event { if !p.HasBet { continue } - wager, resolved := rouletteWagerFor(p.lastSpot, t.Config.Roulette.DoubleZero, t.Config.Roulette.Racetrack) - payout := 0 + totalPayout := 0 result := "loss" + var ids []string + var lines []SettlementLine var err error - if resolved { - if share, ok := wager.covers(pocket); ok { + for _, b := range p.bets { + ids = append(ids, b.wager.ID) + wager, resolved := rouletteWagerFor(b.spot, t.Config.Roulette.DoubleZero, t.Config.Roulette.Racetrack) + payout := 0 + res := "loss" + if resolved { + if share, ok := wager.covers(pocket); ok { + res = "win" + payout, err = share.payout(b.wager.Total) + } + } + if err != nil { + break + } + totalPayout += payout + if res == "win" { result = "win" - payout, err = share.payout(p.Wager.Total) } + lines = append(lines, SettlementLine{Label: b.spot, Odds: rouletteSettlementOdds(wager, pocket), Stake: b.wager.Total, Returned: payout, Result: res}) } - // PayCredits supports a zero return and atomically consumes a losing - // stake too, surfacing persistence errors instead of announcing success. + // PayCredits supports a zero return and atomically consumes every + // losing stake too, surfacing persistence errors instead of + // announcing success. failed := err != nil if !failed { - failed = p.Wallet.PayCredits(payout, p.Wager.ID) != nil + failed = p.Wallet.PayCredits(totalPayout, ids...) != nil } - text := resultTextLabel(p.lastSpot, result, p.Wager.Total, payout) + text := settlementText(lines) public := p.Name + " " + result + "." if failed { text = payoutFailedMessage() @@ -756,7 +787,7 @@ func (t *rouletteTable) settle() []Event { // departures/sit-outs need a balance attached to the settlement. event.ShowBalance = p.SettlementBalanceNeeded(failed) if !failed { - event.Settlement = []SettlementLine{{Label: p.lastSpot, Odds: rouletteSettlementOdds(wager, pocket), Stake: p.Wager.Total, Returned: payout, Result: result}} + event.Settlement = lines if result == "win" { event.Color = "casino_result_win" } else { @@ -778,6 +809,13 @@ func (t *rouletteTable) resetRound() { t.phase = phaseWaiting t.window.close() t.spinRemaining = 0 + for _, p := range t.players.ordered() { + p.lastBets = p.lastBets[:0] + for _, b := range p.bets { + p.lastBets = append(p.lastBets, rebetWager{spot: b.spot, amount: b.wager.Total}) + } + p.bets = nil + } clearBets(t.players.participants()) } func (t *rouletteTable) action(name, action string) []Event { @@ -795,6 +833,38 @@ func (t *rouletteTable) action(name, action string) []Event { } return []Event{t.private(name, "Roulette has no player actions after betting.")} } + +// unbetLocked returns every stake the seat committed this round. Roulette +// allows several wagers in one spin, so it shadows the shared single-stake +// helper. +func (t *rouletteTable) unbetLocked(name string) ([]Event, bool) { + p := t.players.get(name) + if p == nil { + return []Event{t.private(name, "You aren't seated at this table.")}, true + } + if t.phase != phaseWaiting && t.phase != phaseBetting { + return []Event{t.private(name, "You can only take back bets before the round starts.")}, true + } + if !p.HasBet || len(p.bets) == 0 { + return []Event{t.private(name, "Nothing to take back.")}, true + } + total := 0 + var ids []string + for _, b := range p.bets { + total += b.wager.Total + if b.wager.ID != "" { + ids = append(ids, b.wager.ID) + } + } + if p.Wallet != nil { + if err := p.Wallet.RefundStakes(ids...); err != nil { + return []Event{t.private(name, payoutFailedMessage())}, true + } + } + p.HasBet, p.Wager, p.bets = false, Wager{}, nil + return []Event{t.private(name, fmt.Sprintf("You take back %d credits.", total))}, true +} + func (t *rouletteTable) rebet(name string) (string, int) { t.mu.Lock() defer t.mu.Unlock() @@ -803,6 +873,32 @@ func (t *rouletteTable) rebet(name string) (string, int) { } return "", t.Config.MinBet } + +// rebetAll repeats every remembered wager from the previous spin in one +// command, so a bare bet/rebet restores a whole spread. +func (t *rouletteTable) rebetAll(name string, w Wallet) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.players.get(name) + if p == nil { + return []Event{t.private(name, "You aren't playing roulette.")} + } + if t.phase == phasePlaying { + return []Event{t.private(name, "No more bets! The wheel is spinning.")} + } + if len(p.lastBets) == 0 { + return []Event{t.private(name, "Bet on a number, color, or combination. Use table to see all available bets.")} + } + var events []Event + for _, b := range p.lastBets { + ev, ok := t.betLocked(name, b.spot, b.amount, w) + events = append(events, ev...) + if !ok { + break + } + } + return events +} func (t *rouletteTable) status(name string) []Event { t.mu.Lock() defer t.mu.Unlock() @@ -830,7 +926,11 @@ func (t *rouletteTable) markDisconnected(name string) []Event { if p.Wallet != nil { var err error if p.HasBet && t.phase == phasePlaying { - err = p.Wallet.PayCredits(0, p.Wager.ID) + var ids []string + for _, b := range p.bets { + ids = append(ids, b.wager.ID) + } + err = p.Wallet.PayCredits(0, ids...) } else { _, _, err = p.Wallet.RefundOutstanding() } diff --git a/internal/casino/roulette_test.go b/internal/casino/roulette_test.go index a0e04d4..3425f94 100644 --- a/internal/casino/roulette_test.go +++ b/internal/casino/roulette_test.go @@ -281,9 +281,15 @@ func TestRouletteTimedPhasesAndSettlement(t *testing.T) { w := &testWallet{chips: 100} table.bet("a", wheel[index], 10, w) table.bet("a", "red", 10, w) - if w.chips != 90 || table.phase != phaseBetting { + if w.chips != 80 || table.phase != phaseBetting { t.Fatal("duplicate or early spin") } + // Both wagers are on the table: the straight-up wins its 36x total + // return, and the red wager adds 20 when the winning pocket is red. + wantPaid := 360 + if n, err := strconv.Atoi(wheel[index]); err == nil && rouletteRed(n) { + wantPaid += 20 + } table.tick() if table.phase != phaseBetting { t.Fatal("betting ended early") @@ -304,7 +310,7 @@ func TestRouletteTimedPhasesAndSettlement(t *testing.T) { } table.action("a", "leave_after") events = table.tick() - if w.paid != 360 || len(w.open) != 0 || table.hasParticipant("a") { + if w.paid != wantPaid || len(w.open) != 0 || table.hasParticipant("a") { t.Fatalf("bad settlement: %+v", w) } s := events[0].Snapshot.(*RouletteSnapshot) @@ -318,7 +324,7 @@ func TestRouletteTimedPhasesAndSettlement(t *testing.T) { } table.tick() table.refundAll() - if w.paid != 360 { + if w.paid != wantPaid { t.Fatal("double paid") } } diff --git a/internal/casino/table.go b/internal/casino/table.go index 4ab4e3a..f0d5b3d 100644 --- a/internal/casino/table.go +++ b/internal/casino/table.go @@ -108,10 +108,13 @@ func (t *Table) action(name, action string) []Event { return t.game.action(name, func (t *Table) rebet(name string) (string, int) { return t.game.rebet(name) } // RebetAll repeats every remembered wager for a game that keeps more than one -// (currently baccarat's side bets plus main bet). It returns nil for games -// whose bare "bet" is a single-wager repeat. +// (a spread of roulette, Big 6, Crazy Time or baccarat wagers). It returns nil +// for games whose bare "bet" is a single-wager repeat, so the caller falls +// back to the singular rebet path. func (t *Table) RebetAll(name string, wallet Wallet) []Event { - if b, ok := t.game.(*baccaratTable); ok { + if b, ok := t.game.(interface { + rebetAll(string, Wallet) []Event + }); ok { return b.rebetAll(name, wallet) } return nil diff --git a/internal/casino/table_hints.go b/internal/casino/table_hints.go index 3a87c94..ea6aef1 100644 --- a/internal/casino/table_hints.go +++ b/internal/casino/table_hints.go @@ -20,8 +20,8 @@ func (t *rouletteTable) hints(name string) []MenuCommand { commands := []MenuCommand{} if t.phase != phasePlaying && !p.HasBet { commands = append(commands, MenuCommand{Command: "bet N on BET", Desc: "Place one wager"}) - if p.lastBet > 0 { - commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat your previous wager"}) + if len(p.lastBets) > 0 { + commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat all your last wagers"}) } } commands = append(commands, MenuCommand{Command: "stop", Desc: "Leave when your wager settles"}) diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go index d23c8a2..5613a81 100644 --- a/internal/casino/table_ux.go +++ b/internal/casino/table_ux.go @@ -14,6 +14,13 @@ type WagerRow struct { Win string } +// rebetWager is one remembered wager a multi-bet game can replay on a bare +// bet/rebet. +type rebetWager struct { + spot string + amount int +} + const ( hintCommandRules = "rules" hintCommandList = "list" @@ -36,6 +43,18 @@ func withTableHints(commands []MenuCommand, wagerRows []WagerRow) []MenuCommand return commands } +// stripMenuBalance clears the wallet display from a bet response's menus, so +// placing a wager never prints the balance. Explicit status/look and later +// settlements keep their balance. +func stripMenuBalance(events []Event) []Event { + for i := range events { + if events[i].Menu != nil { + events[i].Menu.ShowBalance = false + } + } + return events +} + // RulesText is the shared rules view for table commands and room fixtures. func (c TableConfig) RulesText() string { return rulesTextOnTable(c) } diff --git a/internal/casino/types.go b/internal/casino/types.go index 411b58d..33b5386 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -25,6 +25,7 @@ const ( GameBaccarat GameName = "baccarat" GameRoulette GameName = "roulette" GameBigSix GameName = "big6" + GameCrazyTime GameName = "crazy_time" GameCraps GameName = "craps" ) diff --git a/internal/casino/video_poker.go b/internal/casino/video_poker.go index 98bfda1..10ef03a 100644 --- a/internal/casino/video_poker.go +++ b/internal/casino/video_poker.go @@ -343,7 +343,7 @@ func (t *videoPokerTable) bet(name, spot string, amount int, w Wallet) []Event { } p.remaining = append([]card(nil), deck.cards...) p.active, p.drawn, p.held, p.result = true, false, [5]bool{}, "" - return t.statusLocked(name, false) + return stripMenuBalance(t.statusLocked(name, false)) } func (t *videoPokerTable) rebet(name string) (string, int) { return "", 0 // Coin loading is session state handled by the cabinet's own commands. diff --git a/internal/game/casino.go b/internal/game/casino.go index 2d8024c..fee8936 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -192,7 +192,7 @@ func (g *Game) startCasinoGame(sess *net.Session, p *player.Player, gameName str // Keep slots' spin-as-bet behavior, while Big 6 spins already committed wagers. func (g *Game) executeSpin(sess *net.Session, args []string, rawInput string) { if sess.Player != nil { - if table, playing := g.Casino.Participation(sess.Player.Name); playing && table != nil && table.Config.Game == casino.GameBigSix { + if table, playing := g.Casino.Participation(sess.Player.Name); playing && table != nil && (table.Config.Game == casino.GameBigSix || table.Config.Game == casino.GameCrazyTime) { if len(args) != 0 { sess.WriteLine("Use spin after placing your bets.") return @@ -242,11 +242,14 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { g.emitCasinoEvents(g.Casino.Action(table.RoomID, table.Config.ID, p.Name, strings.TrimSpace("coins "+strings.Join(args, " ")))) return } - // Baccarat repeats every remembered side bet and the main bet on a bare - // "bet"; games with one wager keep the generic single-rebet path below. - if table.Config.Game == casino.GameBaccarat && len(args) == 0 { - g.emitCasinoEvents(g.Casino.RebetAll(table.RoomID, table.Config.ID, p.Name, casinoWallet{g: g, p: p})) - return + // A bare bet/rebet repeats every remembered wager for games that keep + // more than one (roulette, Big 6, Crazy Time, baccarat); games with a single + // wager keep the generic single-rebet path below. + if len(args) == 0 { + if events := g.Casino.RebetAll(table.RoomID, table.Config.ID, p.Name, casinoWallet{g: g, p: p}); events != nil { + g.emitCasinoEvents(events) + return + } } // Table bet syntax: bet | bet N [on BET] | bet min|max [on BET]. // BEFORE an "on" keyword, a token that parses as an amount (or min/max) @@ -257,7 +260,7 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { // an amount and target keyword exist, subsequent numbers belong to the // target even without "on" (bet 10 split 1 2 / bet 10 buy 4). // Other games keep the stricter one-token target. - joinTarget := table.Config.Game == casino.GameRoulette || table.Config.Game == casino.GameCraps + joinTarget := table.Config.Game == casino.GameRoulette || table.Config.Game == casino.GameCraps || table.Config.Game == casino.GameCrazyTime amount, spot, hasAmount, sawOn := 0, "", false, false for i, arg := range args { if strings.EqualFold(arg, "on") { @@ -523,8 +526,9 @@ func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *cas _, isPaiGow := snapshot.(*casino.PaiGowSnapshot) b, isBigSix := snapshot.(*casino.BigSixSnapshot) isBigSix = isBigSix && b.Phase != "playing" && b.Phase != "result" + _, isCrazyTime := snapshot.(*casino.CrazyTimeSnapshot) s, isRoulette := snapshot.(*casino.RouletteSnapshot) - if !isCashHunt && !isCoinFlip && !isPlinko && !isPachinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && (!isRoulette || s.Phase == "playing" || s.Phase == "result") { + if !isCashHunt && !isCoinFlip && !isPlinko && !isPachinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && !isCrazyTime && (!isRoulette || s.Phase == "playing" || s.Phase == "result") { return "", false } if isRoulette && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) { @@ -634,6 +638,9 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot) return renderCrapsSnapshot(s, mode, unicode, sess.Player.OptionString("card_style")) case *casino.BigSixSnapshot: return renderBigSixSnapshot(s, mode, unicode) + case *casino.CrazyTimeSnapshot: + precision, showOdds := g.casinoEdgePrecision(sess) + return renderCrazyTime(s, mode, unicode, showOdds, precision) case *casino.RouletteSnapshot: return renderRouletteSnapshot(s, mode, unicode, size) case *casino.SlotSnapshot: diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go index bdc3add..249537f 100644 --- a/internal/game/casino_ux.go +++ b/internal/game/casino_ux.go @@ -74,6 +74,8 @@ func casinoTableAliases(cfg casino.TableConfig) []string { names = append(names, "pachinko") case casino.GameBigSix: names = append(names, "big-six", "big_six", "big six", "big 6") + case casino.GameCrazyTime: + names = append(names, "crazy time", "crazytime", "crazy-time", "crazy") case casino.GameBlackjack: names = append(names, "bj") case casino.GameBaccarat: diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index c23f89b..14debae 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -305,6 +305,7 @@ var casinoVerbsByGame = map[casino.GameName][]string{ casino.GameVideoPoker: {"bet", "deal", "draw", "hold", "paytable", "denom", "denomination", "hands", "feature"}, casino.GameSlots: {"bet", "spin", "autospin", "autobet"}, casino.GameBigSix: {"bet", "rebet", "unbet", "spin"}, + casino.GameCrazyTime: {"bet", "rebet", "unbet", "spin"}, } // casinoShortAliases maps the registry's terse aliases onto the verb they diff --git a/internal/game/crazy_time_test.go b/internal/game/crazy_time_test.go new file mode 100644 index 0000000..2c07862 --- /dev/null +++ b/internal/game/crazy_time_test.go @@ -0,0 +1,193 @@ +package game + +import ( + "strings" + "testing" + + "thehouseoficarus/internal/casino" +) + +func crazyTimeRoom() string { + return "name: Crazy Time Coliseum\ncasino_tables:\n - id: crazy_time\n game: crazy_time\n min_bet: 1\n max_bet: 1000\n" +} + +func TestCrazyTimePlayAliasAndMenu(t *testing.T) { + g, s, conn := casinoCrapsGame(t, crazyTimeRoom()) + s.Player.Options = map[string]any{"color": "none", "casino_hints": "on"} + g.executePlay(s, []string{"crazy", "time"}, "play crazy time") + table, ok := g.Casino.Participation(s.Player.Name) + if !ok || table == nil || table.Config.Game != casino.GameCrazyTime { + t.Fatal("Crazy Time alias did not seat the player") + } + text := stripANSI(string(conn.data)) + if !strings.Contains(text, "CRAZY TIME") { + t.Fatalf("join output missing the game: %q", text) + } + if !strings.Contains(text, "bet N on BET") { + t.Fatalf("join output missing the bet hint: %q", text) + } + if !strings.Contains(text, "98") { + t.Fatalf("join output missing the RTP: %q", text) + } + conn.data = nil + g.executeRules(s, nil, "rules") + if help := stripANSI(string(conn.data)); !strings.Contains(help, "CRAZY TIME") || !strings.Contains(help, "TOP SLOT") { + t.Fatalf("rules did not open the Crazy Time page: %q", help) + } +} + +func TestCrazyTimeListCommand(t *testing.T) { + g, s, conn := casinoCrapsGame(t, crazyTimeRoom()) + s.Player.Options = map[string]any{"color": "none"} + g.executePlay(s, []string{"crazy time"}, "play crazy time") + conn.data = nil + g.executeList(s, nil, "list") + text := stripANSI(string(conn.data)) + for _, want := range []string{"COIN FLIP", "CASH HUNT", "PACHINKO", "CRAZY TIME", "ALL"} { + if !strings.Contains(text, want) { + t.Fatalf("list missing %s: %q", want, text) + } + } +} + +func TestCrazyTimePlayAndSpin(t *testing.T) { + g, s, conn := casinoCrapsGame(t, crazyTimeRoom()) + s.Player.Options = map[string]any{"color": "none", "casino_hints": "on"} + g.executePlay(s, []string{"crazy time"}, "play crazy time") + conn.data = nil + g.executeBet(s, []string{"5", "on", "cash", "hunt"}, "bet 5 on cash hunt") + if len(s.Player.PendingWagers) != 1 { + t.Fatalf("bet not committed: %+v", s.Player.PendingWagers) + } + if !strings.Contains(stripANSI(string(conn.data)), "CASH HUNT") { + t.Fatalf("bet confirmation missing the target: %q", conn.data) + } + // Spin and drive the whole round (main wheel plus any bonus) to a receipt. + conn.data = nil + g.executeTableAction(s, "spin") + for i := 0; i < 2000 && len(s.Player.PendingWagers) > 0; i++ { + g.CasinoTick() + } + if len(s.Player.PendingWagers) != 0 { + t.Fatalf("round never settled: %+v", s.Player.PendingWagers) + } +} + +func TestCrazyTimeRendering(t *testing.T) { + for _, mode := range []string{"none", "ansi", "xterm256"} { + for _, unicode := range []bool{false, true} { + wheel := &casino.CrazyTimeSnapshot{ + Phase: "result", + Pockets: []string{"1", "2", "5", "10", "COIN FLIP", "CASH HUNT", "PACHINKO", "CRAZY TIME", "1", "2", "5"}, + TopSymbol: "CASH HUNT", + TopMult: 5, + } + rendered := renderCrazyTime(wheel, mode, unicode, false, 0) + if mode == "none" && strings.Contains(rendered, "\x1b") { + t.Fatalf("plain wheel has ANSI escapes: %q", rendered) + } + plain := stripANSI(rendered) + if !strings.Contains(plain, "CASH") || !strings.Contains(plain, "x5") { + t.Fatalf("wheel missing symbol/top multiplier: %q", plain) + } + + bonus := &casino.CrazyTimeSnapshot{ + Phase: "bonus", + Bonus: &casino.CrazyTimeBonusView{ + Kind: casino.CrazyTimeBonusPachinko, + Phase: "result", + PachoPockets: []int{10, 20, -1, 5, 50, 100, 25, 15, 7, 10, 20, 5, 50, 100, 25, 15}, + PachoRow: 16, + PachoLane: 4, + }, + } + rendered = renderCrazyTime(bonus, mode, unicode, false, 0) + if plain := stripANSI(rendered); !strings.Contains(plain, "DBL") || !strings.Contains(plain, "100") { + t.Fatalf("pachinko bonus missing prizes: %q", plain) + } + + coin := &casino.CrazyTimeSnapshot{ + Phase: "bonus", + Bonus: &casino.CrazyTimeBonusView{Kind: casino.CrazyTimeBonusCoinFlip, Phase: "result", CoinLow: 3, CoinHigh: 25, CoinSide: "blue", CoinValue: 25, CoinResult: true}, + } + if plain := stripANSI(renderCrazyTime(coin, mode, unicode, false, 0)); !strings.Contains(plain, "25x") { + t.Fatalf("coin flip bonus missing value: %q", plain) + } + + hunt := &casino.CrazyTimeSnapshot{ + Phase: "bonus", + Bonus: &casino.CrazyTimeBonusView{Kind: casino.CrazyTimeBonusCashHunt, Phase: "result", HuntFinal: []int{2, 5, 10, 25, 50, 100, 500, 15, 20, 2, 5, 10}, HuntPick: 3}, + } + if plain := stripANSI(renderCrazyTime(hunt, mode, unicode, false, 0)); !strings.Contains(plain, "TILE 4") { + t.Fatalf("cash hunt bonus missing the pick: %q", plain) + } + + ct := &casino.CrazyTimeSnapshot{ + Phase: "bonus", + Bonus: &casino.CrazyTimeBonusView{ + Kind: casino.CrazyTimeBonusWheel, + Phase: "result", + CTWheel: []int{10, 15, 20, 25, 50, 100, -1, 10, 15, 20, 25, 50, 100, 10, 15}, + CTOffset: 0, + CTPick: 1, + CTResult: true, + CTValue: 25, + }, + } + if plain := stripANSI(renderCrazyTime(ct, mode, unicode, false, 0)); !strings.Contains(plain, "POINTER") { + t.Fatalf("crazy wheel bonus missing the result: %q", plain) + } + } + } +} + +func TestCrazyTimeBlockedVerbsDoNotLeak(t *testing.T) { + g, s, _ := casinoCrapsGame(t, crazyTimeRoom()) + s.Player.Options = map[string]any{"color": "none"} + g.executePlay(s, []string{"crazy time"}, "play crazy time") + // "sp" must abbreviate to spin, not split (blackjack) or an unknown verb. + got, ok := g.casinoAbbreviation(s, "sp") + if !ok || got != "spin" { + t.Fatalf("sp resolved to %q,%v; want spin", got, ok) + } +} + +func TestCrazyTimeBetOmitsBalanceAndResultShowsItOnce(t *testing.T) { + g, s, conn := casinoCrapsGame(t, crazyTimeRoom()) + s.Player.Options = map[string]any{"color": "none", "casino_hints": "on"} + g.executePlay(s, []string{"crazy time"}, "play crazy time") + conn.data = nil + g.executeBet(s, []string{"5", "on", "all"}, "bet 5 on all") + out := stripANSI(string(conn.data)) + if strings.Contains(out, "Available") { + t.Fatalf("bet printed the wallet: %q", out) + } + if !strings.Contains(out, "all four minigames") { + t.Fatalf("ALL bet not confirmed: %q", out) + } + conn.data = nil + g.executeTableAction(s, "spin") + for i := 0; i < 3000 && len(s.Player.PendingWagers) > 0; i++ { + g.CasinoTick() + } + if len(s.Player.PendingWagers) != 0 { + t.Fatal("round never settled") + } + out = stripANSI(string(conn.data)) + if got := strings.Count(out, "Available "); got != 1 { + t.Fatalf("result printed the wallet %d times, want 1: %q", got, out) + } +} + +func TestCrazyTimeTopSlotOnlyOnResult(t *testing.T) { + pockets := []string{"1", "2", "5", "10", "COIN FLIP", "CASH HUNT", "PACHINKO", "CRAZY TIME", "1", "2", "5"} + spin := &casino.CrazyTimeSnapshot{Phase: "spinning", Pockets: pockets, TopSymbol: "CASH HUNT", TopMult: 5} + if plain := stripANSI(renderCrazyTime(spin, "none", false, false, 0)); strings.Contains(plain, "TOP SLOT") { + t.Fatalf("spinning wheel printed TOP SLOT: %q", plain) + } + result := &casino.CrazyTimeSnapshot{Phase: "result", Pockets: pockets, TopSymbol: "CASH HUNT", TopMult: 5} + plain := stripANSI(renderCrazyTime(result, "none", false, false, 0)) + if !strings.Contains(plain, "TOP SLOT") || !strings.Contains(plain, "x5") { + t.Fatalf("result wheel omitted the Top Slot: %q", plain) + } +} diff --git a/internal/game/look_casino.go b/internal/game/look_casino.go index 500bbfb..38063d1 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, casino.GamePachinko, casino.GameCashHunt: + case casino.GameRoulette, casino.GameBigSix, casino.GamePlinko, casino.GamePachinko, casino.GameCashHunt, casino.GameCrazyTime: // 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 != "" { @@ -111,6 +111,8 @@ func casinoTableSummary(cfg casino.TableConfig) string { return fmt.Sprintf("16 rows; four risks; 1-10 balls; near %.4g%% target RTP", cfg.Plinko.Normalize().RTP) case casino.GamePachinko: return "8 slots; 20 reflected rows; populated fractional board" + case casino.GameCrazyTime: + return "54 pockets; four positive bonus games; 98% RTP; one seat" 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 b467ae9..43f6264 100644 --- a/internal/game/look_target.go +++ b/internal/game/look_target.go @@ -50,7 +50,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { // Felt tables resolve through one data-driven loop; the video-poker cabinet // has its own cabinet-id/pay-table handling. - for _, game := range []casino.GameName{casino.GameCashHunt, casino.GameCoinFlip, casino.GamePlinko, casino.GamePachinko, casino.GameBigSix, casino.GameBlackjack, casino.GameBaccarat, casino.GameRoulette, casino.GameCraps, casino.GamePaiGow} { + for _, game := range []casino.GameName{casino.GameCashHunt, casino.GameCoinFlip, casino.GamePlinko, casino.GamePachinko, casino.GameBigSix, casino.GameCrazyTime, casino.GameBlackjack, casino.GameBaccarat, casino.GameRoulette, casino.GameCraps, casino.GamePaiGow} { if g.lookCasinoTable(sess, lower, game) { return } diff --git a/internal/game/render_crazy_time.go b/internal/game/render_crazy_time.go new file mode 100644 index 0000000..5a8b49d --- /dev/null +++ b/internal/game/render_crazy_time.go @@ -0,0 +1,487 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" +) + +// Crazy Time inks: one bright colour per betting symbol, so the wheel, +// paytable and bonus boards all read consistently. +func crazyTimeInk(label string) int { + switch label { + case "1": + return 46 + case "2": + return 51 + case "5": + return 39 + case "10": + return 201 + case "COIN FLIP": + return 220 + case "CASH HUNT": + return 129 + case "PACHINKO": + return 208 + case "CRAZY TIME": + return 196 + } + return 15 +} + +// crazyTimeShortLabel fits a symbol into a wheel cell. +func crazyTimeShortLabel(label string) string { + switch label { + case "COIN FLIP": + return "COIN" + case "CASH HUNT": + return "CASH" + case "PACHINKO": + return "PACH" + case "CRAZY TIME": + return "CRAZY" + } + return label +} + +func crazyTimeRender(mode string, spec color.ColorSpec, text string) string { + return color.Render(mode, spec, text) +} + +// renderCrazyTimeWheel draws the main 54-pocket wheel as a bordered strip with +// a single pointer. The centre pocket is the landed symbol on a result. +func renderCrazyTimeWheel(s *casino.CrazyTimeSnapshot, mode string, unicode bool) string { + width := 6 + n := len(s.Pockets) + if n == 0 { + return "" + } + b := rouletteBorders(unicode) + result := s.Phase == "result" + if result { + width = 7 + } + center := n / 2 + pointer := 1 + center*(width+1) + width/2 + dim := func(text string) string { return crazyTimeRender(mode, color.ColorSpec{Fg: 240}, text) } + var out strings.Builder + if result && s.TopSymbol != "" { + top := crazyTimeRender(mode, color.ColorSpec{Fg: 244}, "TOP SLOT ") + + crazyTimeRender(mode, color.ColorSpec{Fg: crazyTimeInk(s.TopSymbol), Bold: true}, s.TopSymbol) + if s.TopMult > 1 { + top += crazyTimeRender(mode, color.ColorSpec{Fg: 226, Bold: true}, fmt.Sprintf(" x%d", s.TopMult)) + } else { + top += crazyTimeRender(mode, color.ColorSpec{Fg: 244}, " x1") + } + if s.TopSymbol == s.Pockets[center] && result { + top += crazyTimeRender(mode, color.ColorSpec{Fg: 46, Bold: true}, " HIT!") + } + out.WriteString(top) + out.WriteByte('\n') + } + tip := "\\/" + if unicode { + tip = "▼" + } + out.WriteString(strings.Repeat(" ", pointer) + crazyTimeRender(mode, color.ColorSpec{Fg: 226, Bold: true}, tip) + "\n") + top := b.tl + strings.Repeat(strings.Repeat(b.h, width)+b.teeTop, n-1) + strings.Repeat(b.h, width) + b.tr + bottom := b.bl + strings.Repeat(strings.Repeat(b.h, width)+b.teeBottom, n-1) + strings.Repeat(b.h, width) + b.br + out.WriteString(dim(top) + "\n") + out.WriteString(dim(b.v)) + for i, pocket := range s.Pockets { + label := crazyTimeShortLabel(pocket) + cell := rouletteCenter(label, width) + spec := color.ColorSpec{Fg: crazyTimeInk(pocket), Bold: true} + if result && i != center { + spec = color.ColorSpec{Fg: crazyTimeInk(pocket), Dim: true} + } + out.WriteString(crazyTimeRender(mode, spec, cell)) + out.WriteString(dim(b.v)) + } + out.WriteByte('\n') + out.WriteString(dim(bottom)) + if result { + out.WriteString("\n" + strings.Repeat(" ", pointer-3) + crazyTimeRender(mode, color.ColorSpec{Fg: crazyTimeInk(s.Pockets[center]), Bold: true}, "WINNER")) + } + return out.String() +} + +// crazyTimeFeltPanel draws one betting panel: a bordered box with a symbol +// label and a short payout line. +func crazyTimeFeltPanel(label, sub string, width, ink int, mode string, unicode bool) []string { + b := rouletteBorders(unicode) + lines := []string{ + b.tl + strings.Repeat(b.h, width) + b.tr, + b.v + rouletteCenter(label, width) + b.v, + b.v + rouletteCenter(sub, width) + b.v, + b.bl + strings.Repeat(b.h, width) + b.br, + } + for i := range lines { + lines[i] = crazyTimeRender(mode, color.ColorSpec{Fg: ink, Bold: true}, lines[i]) + } + return lines +} + +// crazyTimeFeltRow prints a row of equal-height panels side by side. +func crazyTimeFeltRow(out *strings.Builder, panels [][]string) { + for line := range panels[0] { + out.WriteString(" ") + for col, panel := range panels { + if col > 0 { + out.WriteByte(' ') + } + out.WriteString(panel[line]) + } + out.WriteByte('\n') + } +} + +// renderCrazyTimeFelt is the table command's felt: every available bet drawn +// as a panel, with ALL sitting between the four minigame squares. +func renderCrazyTimeFelt(s *casino.CrazyTimeSnapshot, mode string, unicode, showOdds bool, precision int) string { + payout := map[string]string{} + for _, sp := range s.Spots { + payout[sp.Label] = sp.Payout + } + var out strings.Builder + out.WriteString(crazyTimeRender(mode, color.ColorSpec{Fg: 253}, + "A giant 54 pocket wheel! Bets on numbers have odds-based payouts. Bet on minigames to play those games when they are spun!")) + out.WriteString("\n\n") + + // The four number bets, in ascending order. + const numWidth, bonusWidth = 14, 29 + var numbers [][]string + for _, label := range []string{"1", "2", "5", "10"} { + sub := "pays " + payout[label] + numbers = append(numbers, crazyTimeFeltPanel(label, sub, numWidth, crazyTimeInk(label), mode, unicode)) + } + crazyTimeFeltRow(&out, numbers) + + // The four minigames in a 2x2 grid with ALL in the middle. + coin := crazyTimeFeltPanel("COIN FLIP", "bonus", bonusWidth, crazyTimeInk("COIN FLIP"), mode, unicode) + cash := crazyTimeFeltPanel("CASH HUNT", "bonus", bonusWidth, crazyTimeInk("CASH HUNT"), mode, unicode) + pach := crazyTimeFeltPanel("PACHINKO", "bonus", bonusWidth, crazyTimeInk("PACHINKO"), mode, unicode) + crazy := crazyTimeFeltPanel("CRAZY TIME", "bonus", bonusWidth, crazyTimeInk("CRAZY TIME"), mode, unicode) + out.WriteByte('\n') + crazyTimeFeltRow(&out, [][]string{coin, cash}) + out.WriteByte('\n') + all := crazyTimeFeltPanel("ALL", "all four", 11, 226, mode, unicode) + // Center the small ALL box under the 2x2 minigame block: two bordered + // panels, one gap between them, and the leading two-space row indent. + rowWidth := 2*(bonusWidth+2) + 1 + indent := 2 + (rowWidth-13)/2 + for _, line := range all { + out.WriteString(strings.Repeat(" ", indent) + line + "\n") + } + out.WriteByte('\n') + crazyTimeFeltRow(&out, [][]string{pach, crazy}) + + rtp := "98%" + if showOdds && s.RTP > 0 { + rtp = fmt.Sprintf("%.*f%%", precision, s.RTP) + } + out.WriteString("\n") + out.WriteString(crazyTimeRender(mode, color.ColorSpec{Fg: 244}, "Every target returns "+rtp+" including the Top Slot multiplier.")) + return out.String() +} + +// ----- Bonus renderers ----- + +func renderCrazyBonus(v *casino.CrazyTimeBonusView, mode string, unicode bool) string { + if v == nil { + return "" + } + switch v.Kind { + case casino.CrazyTimeBonusCoinFlip: + return renderCrazyCoinFlip(v, mode, unicode) + case casino.CrazyTimeBonusCashHunt: + return renderCrazyCashHunt(v, mode, unicode) + case casino.CrazyTimeBonusPachinko: + return renderCrazyPachinko(v, mode, unicode) + case casino.CrazyTimeBonusWheel: + return renderCrazyBonusWheel(v, mode, unicode) + } + return "" +} + +// renderCrazyCoinFlip shows the two weighted faces and the flipping coin. It +// reuses the standalone cabinet's coin art. +func renderCrazyCoinFlip(v *casino.CrazyTimeBonusView, mode string, unicode bool) string { + b := rouletteBorders(unicode) + dim := func(text string) string { return crazyTimeRender(mode, color.ColorSpec{Fg: 245}, text) } + cell := func(label string, value int, ink int) string { + text := fmt.Sprintf("%s %dx", label, value) + return crazyTimeRender(mode, color.ColorSpec{Fg: ink, Bold: true}, rouletteCenter(text, 18)) + } + switch v.Phase { + case "faces": + top := dim(b.tl + strings.Repeat(b.h, 18) + b.teeTop + strings.Repeat(b.h, 18) + b.tr) + mid := dim(b.v) + cell("RED", v.CoinLow, 196) + dim(b.v) + cell("BLUE", v.CoinHigh, 45) + dim(b.v) + bot := dim(b.bl + strings.Repeat(b.h, 18) + b.teeBottom + strings.Repeat(b.h, 18) + b.br) + return crazyTimeRender(mode, color.ColorSpec{Fg: 226, Bold: true}, "COIN FLIP — two prizes, one fair coin") + "\n" + top + "\n" + mid + "\n" + bot + case "flip": + value, side := v.CoinLow, casino.CoinFlipSideRed + if v.CoinFlipAlt { + value, side = v.CoinHigh, casino.CoinFlipSideBlue + } + lines := coinFlipArt(value, side, false, v.CoinFlipAlt, mode, unicode) + return strings.Join(lines, "\n") + case "result": + label := "RED" + ink := 196 + if v.CoinSide == "blue" { + label, ink = "BLUE", 45 + } + art := coinFlipArt(v.CoinValue, v.CoinSide, true, false, mode, unicode) + head := crazyTimeRender(mode, color.ColorSpec{Fg: 226, Bold: true}, fmt.Sprintf("COIN FLIP — %s %dx", label, v.CoinValue)) + return head + "\n" + strings.Join(art, "\n") + "\n" + crazyTimeRender(mode, color.ColorSpec{Fg: ink, Bold: true}, "WIN") + } + return "" +} + +// renderCrazyCashHunt draws the 108-tile board: a preview with values, a +// sealed board, then the final board with the chosen tile highlighted. +func renderCrazyCashHunt(v *casino.CrazyTimeBonusView, mode string, unicode bool) string { + const cols = 10 + const tile = 6 + boardWidth := cols*(tile+1) + 1 + title := crazyTimeRender(mode, color.ColorSpec{Fg: 220, Bold: true}, "CASH HUNT — 108 positive tiles") + if v.Phase == "hidden" { + return title + "\n" + crazyTimeRender(mode, color.ColorSpec{Fg: 245}, "The board is sealed and its positions are mixed...") + } + squares := v.HuntPreview + if v.Phase != "preview" { + squares = v.HuntFinal + } + if len(squares) == 0 { + return title + } + tl, tr, _, ml, mm, mr, bl, bm, br, hz, vt := "┌", "┐", "┬", "├", "┼", "┤", "└", "┴", "┘", "─", "│" + if !unicode { + tl, tr, _, ml, mm, mr, bl, bm, br, hz, vt = "+", "+", "+", "+", "+", "+", "+", "+", "+", "-", "|" + } + border := func(left, mid, right string) string { + var sb strings.Builder + sb.WriteString(left) + for c := 0; c < cols; c++ { + sb.WriteString(strings.Repeat(hz, tile)) + if c < cols-1 { + sb.WriteString(mid) + } + } + sb.WriteString(right) + return sb.String() + } + box := func(s string) string { return crazyTimeRender(mode, color.ColorSpec{Fg: 240, Bg: 16}, s) } + cellText := func(text string) string { + if len(text) >= tile { + return text + } + left := (tile - len(text)) / 2 + return strings.Repeat(" ", left) + text + strings.Repeat(" ", tile-len(text)-left) + } + lines := []string{title, box(tl + strings.Repeat(hz, boardWidth-2) + tr)} + rows := (len(squares) + cols - 1) / cols + for row := 0; row < rows; row++ { + var cells []string + for c := 0; c < cols; c++ { + k := row*cols + c + if k >= len(squares) { + cells = append(cells, strings.Repeat(" ", tile)) + continue + } + text := fmt.Sprintf("%dx", squares[k]) + fg := cashHuntPrizeColor(squares[k]) + bg := 16 + bold := squares[k] > 0 + if v.Phase == "result" && v.HuntPick == k { + text = fmt.Sprintf(">%d<", squares[k]) + fg, bg, bold = 16, 226, true + } + cells = append(cells, crazyTimeRender(mode, color.ColorSpec{Fg: fg, Bg: bg, Bold: bold}, cellText(text))) + } + lines = append(lines, box(vt)+strings.Join(cells, box(vt))+box(vt)) + if row < rows-1 { + lines = append(lines, box(border(ml, mm, mr))) + } + } + lines = append(lines, box(border(bl, bm, br))) + if v.Phase == "result" { + value := 0 + if v.HuntPick >= 0 && v.HuntPick < len(v.HuntFinal) { + value = v.HuntFinal[v.HuntPick] + } + lines = append(lines, crazyTimeRender(mode, color.ColorSpec{Fg: 46, Bold: true}, fmt.Sprintf("TILE %d UNLOCKED — %dx", v.HuntPick+1, value))) + } + return strings.Join(lines, "\n") +} + +// ----- Pachinko bonus ----- + +// crazyPachinkoCell renders one 4-wide prize cell; landing >= 0 highlights it. +func crazyPachinkoCell(value, landing, lane, doubling int, mode string) string { + label := "DBL" + fg := 198 + if value >= 0 { + label = fmt.Sprintf("%d", value) + switch { + case value >= 100: + fg = 51 + case value >= 50: + fg = 220 + case value >= 20: + fg = 208 + case value >= 10: + fg = 46 + default: + fg = 49 + } + } + bold := value < 0 || value >= 20 || (landing == lane && lane >= 0) + if landing == lane && lane >= 0 { + fg = 226 + } + if len(label) > 4 { + label = label[:4] + } + text := fmt.Sprintf("%4s", label) + return color.Render(mode, color.ColorSpec{Fg: fg, Bold: bold}, text) +} + +// renderCrazyPachinko prints the multiplier row at the top, a short drop, then +// the multiplier row again at the bottom with the landing marked. It never +// prints the whole board twice. +func renderCrazyPachinko(v *casino.CrazyTimeBonusView, mode string, unicode bool) string { + if len(v.PachoPockets) == 0 { + return "" + } + ball := "o" + if unicode { + ball = "●" + } + row := func(landing int) string { + var sb strings.Builder + for i, value := range v.PachoPockets { + sb.WriteString(crazyPachinkoCell(value, landing, i, v.PachoDoubling, mode)) + } + return sb.String() + } + switch v.Phase { + case "top": + head := crazyTimeRender(mode, color.ColorSpec{Fg: 226, Bold: true}, "PACHINKO — prizes") + if v.PachoDoubling > 0 { + head += crazyTimeRender(mode, color.ColorSpec{Fg: 201, Bold: true}, fmt.Sprintf(" x%d", 1< 0 { + head += crazyTimeRender(mode, color.ColorSpec{Fg: 201, Bold: true}, fmt.Sprintf(" after %d DOUBLE", v.PachoDoubling)) + } + return head + "\n" + row(v.PachoLane) + } + return "" +} + +// ----- Crazy Time bonus wheel (three pointers) ----- + +func renderCrazyBonusWheel(v *casino.CrazyTimeBonusView, mode string, unicode bool) string { + const width = 6 + wheel := v.CTWheel + n := len(wheel) + if n == 0 { + return "" + } + // The view window is a contiguous run starting at CTOffset. + const cells = 11 + pick := v.CTPick + if pick < 0 || pick >= len(casino.CrazyBonusPointerCells) { + pick = 0 + } + window := make([]int, cells) + for i := 0; i < cells; i++ { + window[i] = wheel[((v.CTOffset+i)%n+n)%n] + } + b := rouletteBorders(unicode) + dim := func(text string) string { return crazyTimeRender(mode, color.ColorSpec{Fg: 240}, text) } + tip := "\\/" + if unicode { + tip = "▼" + } + tipRunes := []rune(tip) + var out strings.Builder + // Pointer line: three arrows over their cells, the chosen one bright. + for i := 0; i < 1+cells*(width+1); i++ { + ch := ' ' + ink, bold := 244, false + for p, cell := range casino.CrazyBonusPointerCells { + col := 1 + cell*(width+1) + width/2 + if i >= col && i < col+len(tipRunes) { + ch = tipRunes[i-col] + if p == pick { + ink, bold = 226, true + } + break + } + } + if ch == ' ' { + out.WriteByte(' ') + } else { + out.WriteString(crazyTimeRender(mode, color.ColorSpec{Fg: ink, Bold: bold}, string(ch))) + } + } + out.WriteByte('\n') + top := b.tl + strings.Repeat(strings.Repeat(b.h, width)+b.teeTop, cells-1) + strings.Repeat(b.h, width) + b.tr + bottom := b.bl + strings.Repeat(strings.Repeat(b.h, width)+b.teeBottom, cells-1) + strings.Repeat(b.h, width) + b.br + out.WriteString(dim(top) + "\n" + dim(b.v)) + for i, value := range window { + label := "x2" + ink := 201 + if value >= 0 { + label = fmt.Sprintf("x%d", value) + ink = 46 + if value >= 100 { + ink = 51 + } else if value >= 50 { + ink = 220 + } else if value >= 25 { + ink = 208 + } + } + spec := color.ColorSpec{Fg: ink, Bold: true} + // Highlight the chosen pointer's column. + if i == casino.CrazyBonusPointerCells[pick] { + spec = color.ColorSpec{Fg: 226, Bg: -1, Bold: true} + if v.CTResult { + spec.Bold = true + } + } + out.WriteString(crazyTimeRender(mode, spec, rouletteCenter(label, width))) + out.WriteString(dim(b.v)) + } + out.WriteByte('\n') + out.WriteString(dim(bottom)) + if v.CTResult { + out.WriteString("\n" + crazyTimeRender(mode, color.ColorSpec{Fg: 226, Bold: true}, fmt.Sprintf("POINTER %d PAYS %dx", pick+1, v.CTValue))) + } + return out.String() +} + +func renderCrazyTime(s *casino.CrazyTimeSnapshot, mode string, unicode bool, showOdds bool, precision int) string { + if s == nil { + return "" + } + if s.Bonus != nil { + return renderCrazyBonus(s.Bonus, mode, unicode) + } + if len(s.Pockets) > 0 { + return renderCrazyTimeWheel(s, mode, unicode) + } + return renderCrazyTimeFelt(s, mode, unicode, showOdds, precision) +} -- cgit v1.2.3