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 }