aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/crazy_time.go
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/crazy_time.go
parentced77d7c89129da6ba4001ab8e3dad265aa41396 (diff)
downloadthehouseoficarus-9fb1f9010526100e12dd1ab1b0ca98401bb6ed75.tar.gz
feat: add casino game: crazy timeHEADmain
Diffstat (limited to 'internal/casino/crazy_time.go')
-rw-r--r--internal/casino/crazy_time.go1440
1 files changed, 1440 insertions, 0 deletions
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
+}