aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
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/casino
parentced77d7c89129da6ba4001ab8e3dad265aa41396 (diff)
downloadthehouseoficarus-main.tar.gz
feat: add casino game: crazy timeHEADmain
Diffstat (limited to 'internal/casino')
-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
16 files changed, 2005 insertions, 47 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.