From 9fb1f9010526100e12dd1ab1b0ca98401bb6ed75 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sat, 10 Oct 2026 04:08:09 -0400 Subject: feat: add casino game: crazy time --- internal/casino/crazy_time.go | 1440 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1440 insertions(+) create mode 100644 internal/casino/crazy_time.go (limited to 'internal/casino/crazy_time.go') 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 +} -- cgit v1.2.3