aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-10 04:08:09 -0400
committerhistoria <[not public]>2026-10-10 04:08:09 -0400
commit9fb1f9010526100e12dd1ab1b0ca98401bb6ed75 (patch)
tree4a128211bfb0dd429bbb559a591d6c4be25c7757 /internal
parentced77d7c89129da6ba4001ab8e3dad265aa41396 (diff)
downloadthehouseoficarus-9fb1f9010526100e12dd1ab1b0ca98401bb6ed75.tar.gz
feat: add casino game: crazy timeHEADmain
Diffstat (limited to 'internal')
-rw-r--r--internal/casino/big_six.go58
-rw-r--r--internal/casino/command_hints_test.go2
-rw-r--r--internal/casino/core.go3
-rw-r--r--internal/casino/craps.go4
-rw-r--r--internal/casino/craps_test.go14
-rw-r--r--internal/casino/crazy_time.go1440
-rw-r--r--internal/casino/crazy_time_test.go323
-rw-r--r--internal/casino/pai_gow.go4
-rw-r--r--internal/casino/registry.go9
-rw-r--r--internal/casino/roulette.go148
-rw-r--r--internal/casino/roulette_test.go12
-rw-r--r--internal/casino/table.go9
-rw-r--r--internal/casino/table_hints.go4
-rw-r--r--internal/casino/table_ux.go19
-rw-r--r--internal/casino/types.go1
-rw-r--r--internal/casino/video_poker.go2
-rw-r--r--internal/game/casino.go23
-rw-r--r--internal/game/casino_ux.go2
-rw-r--r--internal/game/cmd_registry.go1
-rw-r--r--internal/game/crazy_time_test.go193
-rw-r--r--internal/game/look_casino.go4
-rw-r--r--internal/game/look_target.go2
-rw-r--r--internal/game/render_crazy_time.go487
23 files changed, 2707 insertions, 57 deletions
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<<v.PachoDoubling))
+ }
+ return head + "\n" + row(-1)
+ case "drop":
+ // A single compact drop line: the ball slides toward its slot.
+ return strings.Repeat(" ", v.PachoLane*4+1) + color.Render(mode, color.ColorSpec{Fg: 231, Bold: true}, ball)
+ case "result":
+ head := crazyTimeRender(mode, color.ColorSpec{Fg: 226, Bold: true}, "PACHINKO — landed")
+ if v.PachoDoubling > 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)
+}