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/big_six.go | 58 +- internal/casino/command_hints_test.go | 2 +- internal/casino/core.go | 3 + internal/casino/craps.go | 4 +- internal/casino/craps_test.go | 14 + internal/casino/crazy_time.go | 1440 +++++++++++++++++++++++++++++++++ internal/casino/crazy_time_test.go | 323 ++++++++ internal/casino/pai_gow.go | 4 +- internal/casino/registry.go | 9 + internal/casino/roulette.go | 148 +++- internal/casino/roulette_test.go | 12 +- internal/casino/table.go | 9 +- internal/casino/table_hints.go | 4 +- internal/casino/table_ux.go | 19 + internal/casino/types.go | 1 + internal/casino/video_poker.go | 2 +- 16 files changed, 2005 insertions(+), 47 deletions(-) create mode 100644 internal/casino/crazy_time.go create mode 100644 internal/casino/crazy_time_test.go (limited to 'internal/casino') diff --git a/internal/casino/big_six.go b/internal/casino/big_six.go index 36e9c1d..0f4f764 100644 --- a/internal/casino/big_six.go +++ b/internal/casino/big_six.go @@ -99,6 +99,7 @@ type bigSixBet struct { type bigSixPlayer struct { Participant bets []bigSixBet + lastBets []rebetWager lastSpot string lastBet int } @@ -192,8 +193,8 @@ func (t *bigSixTable) commands(name string) []MenuCommand { var c []MenuCommand if p := t.players.get(name); p != nil && t.phase != phasePlaying { c = append(c, MenuCommand{Command: "bet N on BET", Desc: "Add a wager"}) - if p.lastBet > 0 { - c = append(c, MenuCommand{Command: "bet / rebet", Desc: "Repeat your last wager"}) + if len(p.lastBets) > 0 { + c = append(c, MenuCommand{Command: "bet / rebet", Desc: "Repeat all your last wagers"}) } if p.HasBet { c = append(c, MenuCommand{Command: "unbet", Desc: "Take back all wagers"}) @@ -249,31 +250,39 @@ func (t *bigSixTable) leave(name string) []Event { func (t *bigSixTable) bet(name, spot string, amount int, w Wallet) []Event { t.mu.Lock() defer t.mu.Unlock() + events, _ := t.betLocked(name, spot, amount, w) + return events +} + +// betLocked is the locked implementation of bet, shared with the multi-wager +// replay so a rebet holds the table lock across every stake. It reports false +// when no wager was placed. +func (t *bigSixTable) betLocked(name, spot string, amount int, w Wallet) ([]Event, bool) { p := t.players.get(name) if p == nil { - return []Event{t.private(name, "You aren't playing Big 6.")} + return []Event{t.private(name, "You aren't playing Big 6.")}, false } if t.phase == phasePlaying { - return []Event{t.private(name, "No more bets! The wheel is spinning.")} + return []Event{t.private(name, "No more bets! The wheel is spinning.")}, false } if spot == "" { spot = p.lastSpot } resolved, ok := bigSixResolveTarget(spot) if !ok { - return []Event{t.private(name, "Bet on 1, 2, 5, 10, 20, SWORD or SHIELD — an unambiguous prefix is fine. DOUBLE is automatic, not a bet.")} + return []Event{t.private(name, "Bet on 1, 2, 5, 10, 20, SWORD or SHIELD — an unambiguous prefix is fine. DOUBLE is automatic, not a bet.")}, false } spot = resolved if amount < t.Config.MinBet || amount > t.Config.MaxBet { - return []Event{t.private(name, "Big 6 bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")} + return []Event{t.private(name, "Big 6 bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")}, false } // Keep the original aggregate representable for shared menus and refunds. if amount > int(^uint(0)>>1)-p.Wager.Total { - return []Event{t.private(name, "Your total stake is too large.")} + return []Event{t.private(name, "Your total stake is too large.")}, false } wager, err := w.Wager(amount) if err != nil { - return []Event{t.private(name, walletErrorMessage(err))} + return []Event{t.private(name, walletErrorMessage(err))}, false } p.bets = append(p.bets, bigSixBet{spot, wager}) p.HasBet = true @@ -281,7 +290,34 @@ func (t *bigSixTable) bet(name, spot string, amount int, w Wallet) []Event { p.Wager.Total += amount p.lastSpot, p.lastBet, p.SittingOut = spot, amount, false events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d on %s.", name, amount, spot), fmt.Sprintf("You bet %d on %s.", amount, spot))} - return append(events, t.openRound()...) + return append(events, t.openRound()...), true +} + +// rebetAll repeats every remembered wager from the previous round in one +// command, so a bare bet/rebet restores a whole spread. +func (t *bigSixTable) rebetAll(name string, w Wallet) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.players.get(name) + if p == nil { + return []Event{t.private(name, "You aren't playing Big 6.")} + } + if t.phase == phasePlaying { + return []Event{t.private(name, "No more bets! The wheel is spinning.")} + } + if len(p.lastBets) == 0 { + return []Event{t.private(name, "Bet on 1, 2, 5, 10, 20, SWORD or SHIELD — an unambiguous prefix is fine. DOUBLE is automatic, not a bet.")} + } + var events []Event + for _, b := range p.lastBets { + ev, ok := t.betLocked(name, b.spot, b.amount, w) + events = append(events, ev...) + if !ok { + // A rejected stake ends the replay rather than partially betting. + break + } + } + return events } // Motion is planned backwards from an independently sampled uniform outcome. @@ -452,6 +488,10 @@ func (t *bigSixTable) reset() { t.frames = nil t.multiplier.SetInt64(1) for _, p := range t.players.ordered() { + p.lastBets = p.lastBets[:0] + for _, b := range p.bets { + p.lastBets = append(p.lastBets, rebetWager{spot: b.spot, amount: b.wager.Total}) + } p.bets = nil p.HasBet = false p.Wager = Wager{} diff --git a/internal/casino/command_hints_test.go b/internal/casino/command_hints_test.go index b391526..32bdbfa 100644 --- a/internal/casino/command_hints_test.go +++ b/internal/casino/command_hints_test.go @@ -134,7 +134,7 @@ func TestOtherCasinoHintsRespectCommitment(t *testing.T) { t.Fatal("roulette spinning bets advertised") } r.phase = phaseWaiting - r.players.get("a").HasBet = false + r.resetRound() if !hintHas(r.menuEvent("a", true).Menu.Commands, "bet / rebet") { t.Fatal("roulette repeat missing") } diff --git a/internal/casino/core.go b/internal/casino/core.go index 3ba9765..ed56a97 100644 --- a/internal/casino/core.go +++ b/internal/casino/core.go @@ -199,6 +199,9 @@ func gameTitle(game GameName) string { if game == GameBigSix { return "Big 6" } + if game == GameCrazyTime { + return "Crazy Time" + } name := string(game) if name == "" { return "Table" diff --git a/internal/casino/craps.go b/internal/casino/craps.go index 208799e..2b0243b 100644 --- a/internal/casino/craps.go +++ b/internal/casino/craps.go @@ -276,7 +276,7 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event { if amount != requested { msg += fmt.Sprintf(" {casino_emphasis}Rounded up from %d for exact odds units.{/}", requested) } - return []Event{t.private(name, msg), t.betSummary(p), t.reminder(name)} + return stripMenuBalance([]Event{t.private(name, msg), t.betSummary(p), t.reminder(name)}) } return reject("Place an established line/come bet before taking odds.") } @@ -331,7 +331,7 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event { } events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d on %s.", name, amount, spot), private)} events = append(events, t.syncRoundClock()...) - return append(events, t.betSummary(p), t.reminder(name)) + return stripMenuBalance(append(events, t.betSummary(p), t.reminder(name))) } func (t *crapsTable) pay(p *crapsPlayer, b *crapsBet, profit, den int, oddsProfit int, refundOdds bool) bool { payout := crapsReturn(b, profit, den, oddsProfit, refundOdds) diff --git a/internal/casino/craps_test.go b/internal/casino/craps_test.go index 622e541..d453e06 100644 --- a/internal/casino/craps_test.go +++ b/internal/casino/craps_test.go @@ -422,3 +422,17 @@ func TestCrapsFailureRecoveryAndSnapshots(t *testing.T) { t.Fatal("unbet stranded queued departure") } } + +func TestCrapsBetOmitsWallet(t *testing.T) { + tb := crapsFixture() + tb.join("alice") + w := &testWallet{chips: 1000} + for _, e := range tb.bet("alice", "pass", 10, w) { + if e.ShowBalance || (e.Menu != nil && e.Menu.ShowBalance) { + t.Fatalf("craps bet printed the wallet: %+v", e) + } + } + if e := tb.reminder("alice"); e.Menu == nil || !e.Menu.ShowBalance { + t.Fatal("live craps state should still show the wallet") + } +} diff --git a/internal/casino/crazy_time.go b/internal/casino/crazy_time.go new file mode 100644 index 0000000..72b04c3 --- /dev/null +++ b/internal/casino/crazy_time.go @@ -0,0 +1,1440 @@ +package casino + +import ( + "fmt" + "math/big" + "math/rand" + "strings" + "sync" +) + +// Crazy Time is a single-seat betting wheel modelled on Evolution's game of +// the same name. The main wheel reuses Big 6's physical-wheel structure and +// animation; it can stop on a number (1, 2, 5 or 10) or on one of four bonus +// games (Coin Flip, Cash Hunt, Pachinko and the Crazy Time wheel). Every +// bonus is a pure multiplier generator: it only ever returns a positive +// multiple of the bonus stake, because the house edge lives in the main +// wheel's probabilities, not in the bonuses. +// +// RTP is exactly 98% for every betting target. The main wheel alone would +// return far less, so each round also spins a "Top Slot": it picks one of the +// eight symbols uniformly and a multiplier from that symbol's own +// distribution. If the main wheel then stops on that symbol, the win is +// multiplied. Each symbol's multiplier distribution is tuned so that the +// combined return is exactly 98%. +const ( + ctBonusCoinFlip = "coin_flip" + ctBonusCashHunt = "cash_hunt" + ctBonusPachinko = "pachinko" + ctBonusWheel = "crazy_time" + + // ctAll is the convenience target that places one wager on each of the + // four minigames. + ctAll = "ALL" + + // Exported bonus identifiers for the presentation layer. + CrazyTimeBonusCoinFlip = ctBonusCoinFlip + CrazyTimeBonusCashHunt = ctBonusCashHunt + CrazyTimeBonusPachinko = ctBonusPachinko + CrazyTimeBonusWheel = ctBonusWheel + + crazyTimeRTPTarget = 0.98 + crazyTimePockets = 54 +) + +// crazyTimeSpot is one main-wheel symbol and its betting payouts. Pays is the +// profit odds for the number symbols (1:1 for "1", 2:1 for "2", ...); Bonus +// names the bonus game for the four bonus symbols. +type crazyTimeSpot struct { + Label string + Count int + Pays int + Bonus string +} + +func crazyTimeSpots() []crazyTimeSpot { + return []crazyTimeSpot{ + {"1", 21, 1, ""}, + {"2", 13, 2, ""}, + {"5", 7, 5, ""}, + {"10", 4, 10, ""}, + {"COIN FLIP", 4, 0, ctBonusCoinFlip}, + {"CASH HUNT", 2, 0, ctBonusCashHunt}, + {"PACHINKO", 2, 0, ctBonusPachinko}, + {"CRAZY TIME", 1, 0, ctBonusWheel}, + } +} + +// crazyTimeLabels lists the eight betting symbols in wheel display order. +func crazyTimeLabels() []string { + spots := crazyTimeSpots() + labels := make([]string, len(spots)) + for i, s := range spots { + labels[i] = s.Label + } + return labels +} + +// crazyTimeWheel is the fixed, interleaved 54-pocket physical wheel. It is +// never shuffled during a round; a spin picks a pocket index uniformly. +var crazyTimeWheel = func() []string { + var wheel []string + for _, spot := range crazyTimeSpots() { + for i := 0; i < spot.Count; i++ { + wheel = append(wheel, spot.Label) + } + } + rand.New(rand.NewSource(603)).Shuffle(len(wheel), func(i, j int) { wheel[i], wheel[j] = wheel[j], wheel[i] }) + return wheel +}() + +// crazyTimeResolveSpot maps a written target to a betting symbol. An exact +// label always wins over a longer label that shares its prefix, so "1" is the +// $1 symbol and never a prefix of "10". Otherwise an unambiguous prefix is +// accepted, so "co" names COIN FLIP, "ca" CASH HUNT, "cr" CRAZY TIME and "p" +// PACHINKO. It reports false when nothing or more than one symbol matches. +func crazyTimeResolveSpot(s string) (string, bool) { + t := strings.ToUpper(strings.TrimSpace(s)) + t = strings.ReplaceAll(t, "_", " ") + t = strings.ReplaceAll(t, "-", " ") + t = strings.Join(strings.Fields(t), " ") + if t == "" { + return "", false + } + compact := strings.ReplaceAll(t, " ", "") + match := "" + for _, spot := range crazyTimeSpots() { + label := spot.Label + clabel := strings.ReplaceAll(label, " ", "") + if label == t || clabel == compact { + return label, true + } + } + for _, spot := range crazyTimeSpots() { + label := spot.Label + clabel := strings.ReplaceAll(label, " ", "") + if strings.HasPrefix(label, t) || strings.HasPrefix(clabel, compact) { + if match != "" { + return "", false + } + match = label + } + } + return match, match != "" +} + +// crazyTimeIsAll reports whether a written target asks for the convenience +// ALL bet (one wager on each minigame). +func crazyTimeIsAll(s string) bool { + t := strings.ToUpper(strings.TrimSpace(s)) + t = strings.ReplaceAll(t, "_", " ") + t = strings.ReplaceAll(t, "-", " ") + return strings.Join(strings.Fields(t), " ") == ctAll +} + +// ----- Bonus probability tables ----- + +// Coin Flip picks one of two weighted prize distributions and flips a fair +// coin for it. Low averages 3.265625 and high 15.296875 over a 128-ticket +// wheel each, so the bonus averages 9.28125. +var ( + ctCoinLowValues = []int{2, 3, 4, 5} + ctCoinLowWeights = []int{37, 43, 25, 23} + ctCoinHighValues = []int{7, 8, 9, 10, 12, 15, 20, 25, 50, 100} + ctCoinHighWeights = []int{16, 17, 14, 15, 17, 18, 13, 10, 7, 1} +) + +// Cash Hunt is 108 tiles, each carrying a positive multiplier. Weights are +// tickets out of 108; the board averages about 21.85. +const ctHuntSquares = 108 + +var ( + ctHuntValues = []int{2, 5, 10, 15, 25, 50, 100, 500} + ctHuntWeights = []int{30, 25, 20, 15, 8, 5, 3, 2} +) + +// Pachinko is sixteen bottom slots: fifteen fixed prizes drawn from the +// weighted table and one guaranteed DOUBLE. A drop lands uniformly, so the +// doubling chain has expected value sum(fixed)/14. +const ctPachinkoSlots = 16 + +var ( + ctPachinkoValues = []int{2, 5, 7, 10, 15, 20, 25, 50, 100} + ctPachinkoWeights = []int{1, 2, 2, 2, 2, 2, 2, 1, 1} +) + +// The Crazy Time wheel is 64 stops built from the observed Wheel 1: twelve +// 10s, thirteen 15s, seven 20s, eight 25s, six 50s, two 100s and sixteen +// DOUBLEs. A DOUBLE doubles every fixed prize and respins, so its expected +// value is (sum fixed / 64) / (1 - 16/32). +const ( + ctBonusWheelSize = 64 + ctBonusDoubleCount = 16 +) + +var ( + ctBonusWheelValues = []int{10, 15, 20, 25, 50, 100} + ctBonusWheelCounts = []int{12, 13, 7, 8, 6, 2} +) + +func ctWeightedMean(values, weights []int) float64 { + sum, total := 0, 0 + for i := range values { + sum += values[i] * weights[i] + total += weights[i] + } + if total == 0 { + return 0 + } + return float64(sum) / float64(total) +} + +func ctCoinFlipMean() float64 { + return (ctWeightedMean(ctCoinLowValues, ctCoinLowWeights) + ctWeightedMean(ctCoinHighValues, ctCoinHighWeights)) / 2 +} + +func ctCashHuntMean() float64 { return ctWeightedMean(ctHuntValues, ctHuntWeights) } + +func ctPachinkoMean() float64 { + // Fifteen fixed prizes plus one DOUBLE: V = sum(fixed)/16 + (1/16)*2V. + return 15 * ctWeightedMean(ctPachinkoValues, ctPachinkoWeights) / 14 +} + +func ctBonusWheelMean() float64 { + sum := 0 + for i := range ctBonusWheelValues { + sum += ctBonusWheelValues[i] * ctBonusWheelCounts[i] + } + return (float64(sum) / ctBonusWheelSize) / (1 - 2*float64(ctBonusDoubleCount)/ctBonusWheelSize) +} + +func crazyTimeBonusMean(kind string) float64 { + switch kind { + case ctBonusCoinFlip: + return ctCoinFlipMean() + case ctBonusCashHunt: + return ctCashHuntMean() + case ctBonusPachinko: + return ctPachinkoMean() + case ctBonusWheel: + return ctBonusWheelMean() + } + return 0 +} + +// crazyTimeBaseRTP is the return of a single wager before the Top Slot +// multiplier: the probability the wheel lands on the target times the +// target's total-return multiplier. +func crazyTimeBaseRTP(spot string) float64 { + for _, s := range crazyTimeSpots() { + if s.Label != spot { + continue + } + if s.Pays > 0 { + return float64(s.Count) * float64(s.Pays+1) / crazyTimePockets + } + return float64(s.Count) / crazyTimePockets * crazyTimeBonusMean(s.Bonus) + } + return 0 +} + +// crazyTimeWinMultiplier is the total-return multiplier of a winning number +// bet, used by the house-edge reference and settlement. +func crazyTimeWinMultiplier(spot string, bonusValue int) int { + for _, s := range crazyTimeSpots() { + if s.Label != spot { + continue + } + if s.Pays > 0 { + return s.Pays + 1 + } + return bonusValue + } + return 0 +} + +// ----- Top Slot ----- + +// ctTopDist is a weighted integer multiplier distribution: mostly 1x misses +// with occasional larger multipliers. +type ctTopDist struct { + values []int + weights []float64 + total float64 +} + +// ctBuildTopDist builds a multiplier distribution whose mean is exactly +// target. A fixed base distribution over 2..50 is used, and the weight of a +// 1x multiplier is solved so the mean lands on target. +func ctBuildTopDist(target float64) ctTopDist { + baseValues := []int{2, 3, 4, 5, 7, 10, 15, 20, 25, 50} + baseWeights := []float64{10, 8, 8, 6, 5, 4, 2, 2, 1, 1} + sum, weight := 0.0, 0.0 + for i := range baseValues { + sum += float64(baseValues[i]) * baseWeights[i] + weight += baseWeights[i] + } + if target <= 1 { + return ctTopDist{values: []int{1}, weights: []float64{1}, total: 1} + } + ones := (sum - target*weight) / (target - 1) + if ones < 0 { + ones = 0 + } + values := append([]int{1}, baseValues...) + weights := append([]float64{ones}, baseWeights...) + total := ones + weight + return ctTopDist{values: values, weights: weights, total: total} +} + +func (d ctTopDist) draw(rng *rand.Rand) int { + if d.total <= 0 { + return 1 + } + x := rng.Float64() * d.total + for i, w := range d.weights { + x -= w + if x < 0 { + return d.values[i] + } + } + return d.values[len(d.values)-1] +} + +// crazyTimeTopTarget is the mean multiplier a symbol's Top Slot distribution +// must have for its combined return to be exactly crazyTimeRTPTarget. The Top +// Slot symbol is uniform, so only one eighth of the time does it boost a win. +func crazyTimeTopTarget(spot string) float64 { + base := crazyTimeBaseRTP(spot) + if base <= 0 { + return 1 + } + return 1 + 8*(crazyTimeRTPTarget/base-1) +} + +var crazyTimeTopDists = func() map[string]ctTopDist { + dists := make(map[string]ctTopDist, len(crazyTimeSpots())) + for _, s := range crazyTimeSpots() { + dists[s.Label] = ctBuildTopDist(crazyTimeTopTarget(s.Label)) + } + return dists +}() + +// crazyTimeTopSlotSymbol picks the Top Slot symbol uniformly from the eight +// betting targets. +func crazyTimeTopSlotSymbol(rng *rand.Rand) string { + labels := crazyTimeLabels() + return labels[rng.Intn(len(labels))] +} + +// ----- Snapshot types ----- + +// CrazyTimeBonusView carries the live state of whatever bonus game is running. +// One struct covers all four bonuses; Kind selects which fields the renderer +// reads. +type CrazyTimeBonusView struct { + Kind string + Phase string + + // Coin Flip. + CoinLow int + CoinHigh int + CoinSide string + CoinValue int + CoinFlipAlt bool + CoinResult bool + + // Cash Hunt. + HuntPreview []int + HuntFinal []int + HuntPick int + + // Pachinko. + PachoPockets []int + PachoRow int + PachoLane int + PachoDoubling int + PachoTop bool + + // Crazy Time wheel. + CTWheel []int + CTOffset int + CTPick int + CTDoubling int + CTResult bool + CTValue int +} + +// CrazyTimeBetView is one live wager shown by status/look. +type CrazyTimeBetView struct { + Spot string + Amount int +} + +// CrazyTimeSpotView is one paytable row. +type CrazyTimeSpotView struct { + Label string + Payout string + Bonus bool +} + +type CrazyTimeSnapshot struct { + Phase string + Pockets []string + TopSymbol string + TopMult int + Multiplier int + Wager int + Bets []CrazyTimeBetView + Bonus *CrazyTimeBonusView + Spots []CrazyTimeSpotView + RTP float64 +} + +func (*CrazyTimeSnapshot) snapshot() {} + +// ----- Engine ----- + +const ( + ctIdle = "idle" + ctSpinning = "spinning" + ctBonusing = "bonus" + ctPending = "pending" +) + +// ctPrint is one already-planned bonus animation frame: a structured view, +// a plain message, or both. +type ctPrint struct { + delay int + msg string + view *CrazyTimeBonusView + last bool +} + +type crazyTimeBet struct { + spot string + wager Wager +} + +type crazyTimeTable struct { + *tableBase + mu sync.Mutex + rng *rand.Rand + name string + bets []crazyTimeBet + lastSpot string + lastAmount int + // lastBets remembers the most recent stake on each symbol so a bare bet or + // rebet can restore a whole spread at once. + lastBets map[string]int + wallet Wallet + phase string + + // Top Slot. + topSymbol string + topMult int + + // Main wheel. + winIndex int + winSymbol string + position int + frames []bigSixFrame + frame int + wait int + + // Bonus. + bonusKind string + bonusValue int + prints []ctPrint + print int + printWait int +} + +func newCrazyTimeTable(room int, c TableConfig, rng *rand.Rand) *crazyTimeTable { + return &crazyTimeTable{tableBase: &tableBase{RoomID: room, Config: c}, rng: rng, phase: ctIdle} +} + +func validateCrazyTime(c TableConfig) error { + if c.MaxPlayers > 1 { + return fmt.Errorf("crazy time has one seat") + } + return nil +} + +func crazyTimeRules(TableConfig) string { + return fmt.Sprintf("Crazy Time: a 54-pocket wheel. Bet on 1 (1:1), 2 (2:1), 5 (5:1), 10 (10:1) or one of the four bonuses: COIN FLIP, CASH HUNT, PACHINKO or CRAZY TIME.\n"+ + "Place any number of wagers, then spin. A Top Slot picks one of the eight symbols and a multiplier; if the wheel stops on that same symbol, your win is multiplied.\n"+ + "Bonuses are always pure multipliers: COIN FLIP (fair coin, 2-100x), CASH HUNT (108 positive tiles, up to 500x), PACHINKO (positive prizes with chained DOUBLEs) and CRAZY TIME (a 64-stop three-pointer wheel with DOUBLE respins).\n"+ + "Every target returns exactly %.0f%% including the Top Slot; there is no house edge inside any bonus.\n"+ + "One seat only. Bets are committed at spin. Shutdown refunds an unsettled round; a failed payout keeps its stakes pending, and settle retries the same outcome.", + 100*crazyTimeRTPTarget) +} + +func crazyTimeHouseEdge(_ TableConfig, bet string) HouseEdge { + if bet == "" || bet == "main" || crazyTimeIsAll(bet) { + return edge(1 - crazyTimeRTPTarget) + } + spot, ok := crazyTimeResolveSpot(bet) + if !ok { + return unknownEdge("unrecognized Crazy Time target") + } + return edge(1 - crazyTimeRTPTargetFor(spot)) +} + +// crazyTimeRTPTargetFor returns the exact combined return of a target, +// including the Top Slot and the target's bonus multiplier distribution. +func crazyTimeRTPTargetFor(spot string) float64 { + base := crazyTimeBaseRTP(spot) + dist, ok := crazyTimeTopDists[spot] + if !ok { + return base + } + mean := 1.0 + if dist.total > 0 { + mean = 0 + for i, w := range dist.weights { + mean += float64(dist.values[i]) * w + } + mean /= dist.total + } + return base * (1 + (mean-1)/8) +} + +// ----- tableGame plumbing ----- + +func (t *crazyTimeTable) base() *tableBase { return t.tableBase } + +func (t *crazyTimeTable) idle() bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.name == "" +} + +func (t *crazyTimeTable) hasParticipant(n string) bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.name != "" && t.name == n +} + +func (t *crazyTimeTable) busy(n string) bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.name == n && (t.phase == ctSpinning || t.phase == ctBonusing) +} + +func (t *crazyTimeTable) overview() string { return "Crazy Time / 54-pocket bonus wheel" } + +func (t *crazyTimeTable) seats() []string { + t.mu.Lock() + defer t.mu.Unlock() + if t.name == "" { + return nil + } + return []string{t.name} +} + +func (t *crazyTimeTable) state() string { + t.mu.Lock() + defer t.mu.Unlock() + switch t.phase { + case ctSpinning: + return "spinning" + case ctBonusing: + return "bonus: " + t.bonusKind + case ctPending: + return "payout pending" + default: + return "waiting" + } +} + +func (t *crazyTimeTable) wagers(string) []WagerRow { + var rows []WagerRow + for _, s := range crazyTimeSpots() { + if s.Pays > 0 { + rows = append(rows, WagerRow{Bet: s.Label, Payout: fmt.Sprintf("%d:1", s.Pays), Win: "Wheel stops on " + s.Label}) + continue + } + rows = append(rows, WagerRow{Bet: s.Label, Payout: "varies", Win: "Wheel stops on " + s.Label + "; a positive bonus multiplier"}) + } + rows = append(rows, WagerRow{Bet: ctAll, Payout: "varies", Win: "Wheel stops on any minigame; plays that bonus"}) + return rows +} + +func (t *crazyTimeTable) hintsLocked() []MenuCommand { + if t.phase == ctSpinning { + return []MenuCommand{ + {Command: "table", Desc: "Show the wheel"}, + {Command: "rules", Desc: "Crazy Time rules"}, + } + } + if t.phase == ctBonusing { + return []MenuCommand{ + {Command: "table", Desc: "Show the bonus board"}, + {Command: "rules", Desc: "Crazy Time rules"}, + } + } + if t.phase == ctPending { + return []MenuCommand{ + {Command: "settle", Desc: "Retry the pending payout"}, + {Command: "rules", Desc: "Crazy Time rules"}, + } + } + c := []MenuCommand{ + {Command: "bet N on BET", Desc: "Wager on 1, 2, 5, 10, ALL or a bonus"}, + } + if len(t.lastBets) > 0 { + c = append(c, MenuCommand{Command: "bet / rebet", Desc: "Repeat all your last wagers"}) + } + if len(t.bets) > 0 { + c = append(c, MenuCommand{Command: "unbet", Desc: "Take back all wagers"}) + } + c = append(c, MenuCommand{Command: "spin", Desc: "Start the wheel"}) + c = append(c, MenuCommand{Command: "table", Desc: "Show the felt"}, MenuCommand{Command: "stop", Desc: "Leave after your bets settle"}) + return withTableHints(c, t.wagers("")) +} + +func (t *crazyTimeTable) hints(n string) []MenuCommand { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + return t.hintsLocked() +} + +func (t *crazyTimeTable) viewLocked(phase string) *CrazyTimeSnapshot { + s := &CrazyTimeSnapshot{ + Phase: phase, + TopSymbol: t.topSymbol, + TopMult: t.topMult, + Wager: t.wagerTotalLocked(), + Multiplier: t.topMult, + RTP: 100 * crazyTimeRTPTarget, + } + for _, b := range t.bets { + s.Bets = append(s.Bets, CrazyTimeBetView{Spot: b.spot, Amount: b.wager.Total}) + } + if phase == "paytable" { + for _, s2 := range crazyTimeSpots() { + if s2.Pays > 0 { + s.Spots = append(s.Spots, CrazyTimeSpotView{Label: s2.Label, Payout: fmt.Sprintf("%d:1", s2.Pays)}) + } else { + s.Spots = append(s.Spots, CrazyTimeSpotView{Label: s2.Label, Payout: "bonus", Bonus: true}) + } + } + } + return s +} + +func (t *crazyTimeTable) wagerTotalLocked() int { + total := 0 + for _, b := range t.bets { + total += b.wager.Total + } + return total +} + +// stripLocked returns the wheel pockets around the current position, centred +// on the position when a result is being shown. +func (t *crazyTimeTable) stripLocked(result bool) []string { + count := 11 + if result { + count = 9 + } + pockets := make([]string, 0, count) + for i := 0; i < count; i++ { + pockets = append(pockets, crazyTimeWheel[(t.position+i-count/2+len(crazyTimeWheel))%len(crazyTimeWheel)]) + } + return pockets +} + +func (t *crazyTimeTable) snapshotLocked(phase string, result bool) *CrazyTimeSnapshot { + s := t.viewLocked(phase) + s.Pockets = t.stripLocked(result) + return s +} + +// board is the table command's reference view: the full paytable plus the +// live wheel only while one is in flight. +func (t *crazyTimeTable) board(string) Snapshot { + t.mu.Lock() + defer t.mu.Unlock() + if t.phase == ctSpinning { + return t.snapshotLocked(ctSpinning, false) + } + if t.phase == ctBonusing && len(t.prints) > 0 { + i := t.print + if i >= len(t.prints) { + i = len(t.prints) - 1 + } + return t.printSnapshotLocked(t.prints[i]) + } + return t.viewLocked("paytable") +} + +func (t *crazyTimeTable) statusLocked(n string) []Event { + phase := t.phase + if phase == "" { + phase = ctIdle + } + e := t.private(n, "") + e.Color = "casino_menu" + if t.phase == ctSpinning { + e.Snapshot = t.snapshotLocked(ctSpinning, false) + } else if t.phase == ctBonusing && len(t.prints) > 0 { + i := t.print + if i >= len(t.prints) { + i = len(t.prints) - 1 + } + e.Snapshot = t.printSnapshotLocked(t.prints[i]) + } else { + e.Snapshot = t.viewLocked("paytable") + } + if n == t.name { + e.Menu = &MenuView{ShowBalance: true, Commands: t.hintsLocked()} + } + return []Event{e} +} + +func (t *crazyTimeTable) status(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + return t.statusLocked(n) +} + +func (t *crazyTimeTable) join(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if t.name != "" { + return []Event{t.private(n, "Crazy Time is occupied.")} + } + t.resetLocked() + t.name = n + e := t.private(n, "") + e.Type, e.Player = EventJoined, n + return append([]Event{e}, t.statusLocked(n)...) +} + +func (t *crazyTimeTable) resetLocked() { + t.bets = nil + t.wallet = nil + t.phase = ctIdle + t.topSymbol, t.topMult = "", 0 + t.winIndex, t.winSymbol, t.position = 0, "", 0 + t.frames, t.frame, t.wait = nil, 0, 0 + t.bonusKind, t.bonusValue = "", 0 + t.prints, t.print, t.printWait = nil, 0, 0 +} + +func (t *crazyTimeTable) clearLocked() { + t.name = "" + t.lastSpot, t.lastAmount = "", 0 + t.lastBets = nil + t.resetLocked() +} + +func (t *crazyTimeTable) leave(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + if t.phase == ctSpinning || t.phase == ctBonusing { + return []Event{t.private(n, "Your Crazy Time wagers are committed until the round ends.")} + } + if t.phase == ctPending { + return []Event{t.private(n, "A payout is still pending. Use settle before leaving.")} + } + t.clearLocked() + e := t.playerAction(n, n+" leaves Crazy Time.", "You leave Crazy Time.") + e.Type, e.Player = EventLeft, n + return []Event{e} +} + +func (t *crazyTimeTable) rebet(n string) (string, int) { + t.mu.Lock() + defer t.mu.Unlock() + if n == t.name && t.phase == ctIdle && t.lastAmount > 0 { + return t.lastSpot, t.lastAmount + } + return "", 0 +} + +// ----- Betting ----- + +func (t *crazyTimeTable) bet(n, spot string, amount int, w Wallet) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + if t.phase == ctSpinning || t.phase == ctBonusing { + return []Event{t.private(n, "No more bets! The wheel is spinning.")} + } + if t.phase == ctPending { + return []Event{t.private(n, "A payout is still pending. Use settle first.")} + } + if strings.TrimSpace(spot) == "" { + spot = t.lastSpot + } + if crazyTimeIsAll(spot) { + return t.betAllLocked(n, amount, w) + } + resolved, ok := crazyTimeResolveSpot(spot) + if !ok { + return []Event{t.private(n, "Bet on 1, 2, 5, 10, ALL, COIN FLIP, CASH HUNT, PACHINKO or CRAZY TIME — an unambiguous prefix is fine.")} + } + events, _ := t.placeBetLocked(n, resolved, amount, w) + return events +} + +// placeBetLocked commits one wager on an already-resolved symbol. It reports +// false when no wager was placed, so a multi-bet replay can stop on the first +// rejected stake. +func (t *crazyTimeTable) placeBetLocked(n, resolved string, amount int, w Wallet) ([]Event, bool) { + if amount < t.Config.MinBet || amount > t.Config.MaxBet { + return []Event{t.private(n, "Crazy Time bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")}, false + } + if amount > int(^uint(0)>>1)-t.wagerTotalLocked() { + return []Event{t.private(n, "Your total stake is too large.")}, false + } + if w == nil { + return []Event{t.private(n, "No wallet is available.")}, false + } + wager, err := w.Wager(amount) + if err != nil { + return []Event{t.private(n, walletErrorMessage(err, "Not enough chips and credits."))}, false + } + t.wallet = w + t.bets = append(t.bets, crazyTimeBet{spot: resolved, wager: wager}) + if t.lastBets == nil { + t.lastBets = map[string]int{} + } + t.lastBets[resolved] = amount + t.lastSpot, t.lastAmount = resolved, amount + return []Event{t.playerAction(n, fmt.Sprintf("%s bets %d on %s.", n, amount, resolved), fmt.Sprintf("You bet %d on %s.", amount, resolved))}, true +} + +// betAllLocked treats ALL as one wager on each of the four minigames, so a +// single command covers every bonus and each stake keeps its own 98% return. +func (t *crazyTimeTable) betAllLocked(n string, amount int, w Wallet) []Event { + if amount < t.Config.MinBet || amount > t.Config.MaxBet { + return []Event{t.private(n, "Crazy Time bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")} + } + if w == nil { + return []Event{t.private(n, "No wallet is available.")} + } + if amount > (int(^uint(0)>>1)-t.wagerTotalLocked())/4 { + return []Event{t.private(n, "Your total stake is too large.")} + } + var placed []crazyTimeBet + var ids []string + for _, s := range crazyTimeSpots() { + if s.Bonus == "" { + continue + } + wager, err := w.Wager(amount) + if err != nil { + if len(ids) > 0 { + _ = w.RefundStakes(ids...) + } + return []Event{t.private(n, walletErrorMessage(err, "Not enough chips and credits for the ALL bet."))} + } + placed = append(placed, crazyTimeBet{spot: s.Label, wager: wager}) + ids = append(ids, wager.ID) + } + t.wallet = w + t.bets = append(t.bets, placed...) + if t.lastBets == nil { + t.lastBets = map[string]int{} + } + for _, b := range placed { + t.lastBets[b.spot] = amount + } + t.lastSpot, t.lastAmount = ctAll, amount + return []Event{t.playerAction(n, fmt.Sprintf("%s bets %d on ALL.", n, amount), fmt.Sprintf("You bet %d on all four minigames.", amount))} +} + +// rebetAll repeats every remembered wager from the previous round in one +// command, so a bare bet/rebet restores a whole multi-symbol spread. +func (t *crazyTimeTable) rebetAll(n string, w Wallet) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return []Event{t.private(n, "You aren't playing Crazy Time.")} + } + if t.phase == ctSpinning || t.phase == ctBonusing { + return []Event{t.private(n, "No more bets! The wheel is spinning.")} + } + if t.phase == ctPending { + return []Event{t.private(n, "A payout is still pending. Use settle first.")} + } + var events []Event + placed := 0 + for _, s := range crazyTimeSpots() { + amount := t.lastBets[s.Label] + if amount <= 0 { + continue + } + ev, ok := t.placeBetLocked(n, s.Label, amount, w) + events = append(events, ev...) + if !ok { + return events + } + placed++ + } + if placed == 0 { + return []Event{t.private(n, "Bet on 1, 2, 5, 10, ALL, COIN FLIP, CASH HUNT, PACHINKO or CRAZY TIME.")} + } + return events +} + +func (t *crazyTimeTable) menuEventLocked(n string) Event { + e := t.private(n, "") + e.Color = "casino_menu" + e.Menu = &MenuView{ShowBalance: true, Commands: t.hintsLocked()} + return e +} + +func (t *crazyTimeTable) action(n, action string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + f := strings.Fields(strings.ToLower(strings.TrimSpace(action))) + if len(f) == 0 { + return t.statusLocked(n) + } + switch f[0] { + case "spin": + if t.phase == ctSpinning || t.phase == ctBonusing { + return []Event{t.private(n, "The wheel is already spinning.")} + } + if t.phase == ctPending { + return []Event{t.private(n, "Use settle to retry the pending payout.")} + } + if len(t.bets) == 0 { + return []Event{t.private(n, "Place your bets before spinning.")} + } + return t.startSpinLocked() + case "unbet": + if t.phase != ctIdle { + return []Event{t.private(n, "You cannot take back bets after the wheel starts.")} + } + if len(t.bets) == 0 { + return []Event{t.private(n, "Nothing to take back.")} + } + ids := make([]string, 0, len(t.bets)) + total := 0 + for _, b := range t.bets { + ids = append(ids, b.wager.ID) + total += b.wager.Total + } + if t.wallet != nil { + if err := t.wallet.RefundStakes(ids...); err != nil { + return []Event{t.private(n, payoutFailedMessage())} + } + } + t.bets = nil + return []Event{t.private(n, fmt.Sprintf("You take back %d credits.", total))} + case "settle": + if t.phase == ctPending { + return t.settleLocked() + } + return []Event{t.private(n, "There is no pending payout.")} + } + return []Event{t.private(n, "Place bets, then spin. Use rules for Crazy Time help.")} +} + +// ----- Main wheel animation ----- + +func (t *crazyTimeTable) startSpinLocked() []Event { + t.phase = ctSpinning + t.topSymbol = crazyTimeTopSlotSymbol(t.rng) + t.topMult = crazyTimeTopDists[t.topSymbol].draw(t.rng) + t.winIndex = t.rng.Intn(len(crazyTimeWheel)) + t.winSymbol = crazyTimeWheel[t.winIndex] + t.frames = bigSixAnimation() + t.frame = 0 + distance := 0 + for _, f := range t.frames { + distance += f.move + } + t.position = (t.winIndex - distance%len(crazyTimeWheel) + len(crazyTimeWheel)) % len(crazyTimeWheel) + t.wait = t.frames[0].delay + 1 + e := t.event("No more bets! The Crazy Time wheel begins to turn...", true) + e.Snapshot = t.snapshotLocked(ctSpinning, false) + return []Event{e} +} + +func (t *crazyTimeTable) tick() []Event { + t.mu.Lock() + defer t.mu.Unlock() + switch t.phase { + case ctSpinning: + return t.tickSpinLocked() + case ctBonusing: + return t.tickBonusLocked() + } + return nil +} + +func (t *crazyTimeTable) tickSpinLocked() []Event { + t.wait-- + if t.wait > 0 { + return nil + } + t.position = (t.position + t.frames[t.frame].move) % len(crazyTimeWheel) + t.frame++ + if t.frame == len(t.frames) { + return t.revealLocked() + } + t.wait = t.frames[t.frame].delay + e := t.event("", true) + e.Snapshot = t.snapshotLocked(ctSpinning, false) + return []Event{e} +} + +// revealLocked stops the main wheel. A number settles immediately; a bonus +// starts its own animation and settles on its final frame. +func (t *crazyTimeTable) revealLocked() []Event { + t.position = t.winIndex + topMatch := t.topSymbol == t.winSymbol + msg := "The pointer stops on {casino_emphasis}" + t.winSymbol + "{/}!" + if topMatch && t.topMult > 1 { + msg += fmt.Sprintf(" The Top Slot multiplies it by %d!", t.topMult) + } + e := t.event(msg, true) + e.Snapshot = t.snapshotLocked("result", true) + events := []Event{e} + if t.isBonusLocked() { + return append(events, t.beginBonusLocked()...) + } + return append(events, t.settleLocked()...) +} + +func (t *crazyTimeTable) isBonusLocked() bool { + return crazyTimeWinMultiplier(t.winSymbol, 0) == 0 +} + +func (t *crazyTimeTable) tickBonusLocked() []Event { + if t.printWait > 0 { + t.printWait-- + return nil + } + if t.print >= len(t.prints) { + return t.settleLocked() + } + p := t.prints[t.print] + t.print++ + e := t.private(t.name, p.msg) + if p.view != nil { + e.Snapshot = &CrazyTimeSnapshot{ + Phase: ctBonusing, + TopSymbol: t.topSymbol, + TopMult: t.topMult, + Multiplier: t.topMult, + Bonus: p.view, + } + } + if p.delay > 0 { + t.printWait = p.delay + } + if p.last { + return append([]Event{e}, t.settleLocked()...) + } + return []Event{e} +} + +func (t *crazyTimeTable) printSnapshotLocked(p ctPrint) *CrazyTimeSnapshot { + return &CrazyTimeSnapshot{ + Phase: ctBonusing, + TopSymbol: t.topSymbol, + TopMult: t.topMult, + Multiplier: t.topMult, + Bonus: p.view, + } +} + +// ----- Bonuses ----- + +func (t *crazyTimeTable) beginBonusLocked() []Event { + kind := "" + for _, s := range crazyTimeSpots() { + if s.Label == t.winSymbol { + kind = s.Bonus + } + } + t.bonusKind = kind + t.bonusValue = 0 + switch kind { + case ctBonusCoinFlip: + t.buildCoinFlipLocked() + case ctBonusCashHunt: + t.buildCashHuntLocked() + case ctBonusPachinko: + t.buildPachinkoLocked() + case ctBonusWheel: + t.buildBonusWheelLocked() + } + t.phase = ctBonusing + sym := t.winSymbol + announce := t.event("{casino_emphasis}"+sym+"{/} bonus! The game is played now.", true) + if len(t.prints) == 0 { + return []Event{announce} + } + first := t.prints[0] + t.print = 1 + if first.delay > 0 { + t.printWait = first.delay + } + events := []Event{announce, t.nextBonusEventLocked(first)} + if first.last { + events = append(events, t.settleLocked()...) + } + return events +} + +func (t *crazyTimeTable) nextBonusEventLocked(p ctPrint) Event { + e := t.private(t.name, p.msg) + if p.view != nil { + e.Snapshot = t.printSnapshotLocked(p) + } + return e +} + +func ctWeightedPick(rng *rand.Rand, values, weights []int) int { + total := 0 + for _, w := range weights { + total += w + } + ticket := rng.Intn(total) + for i, w := range weights { + if ticket < w { + return values[i] + } + ticket -= w + } + return values[len(values)-1] +} + +func (t *crazyTimeTable) buildCoinFlipLocked() { + low := ctWeightedPick(t.rng, ctCoinLowValues, ctCoinLowWeights) + high := ctWeightedPick(t.rng, ctCoinHighValues, ctCoinHighWeights) + side := "red" + value := low + if t.rng.Intn(2) == 0 { + side, value = "blue", high + } + t.bonusValue = value + view := &CrazyTimeBonusView{Kind: ctBonusCoinFlip, Phase: "faces", CoinLow: low, CoinHigh: high} + t.prints = append(t.prints, ctPrint{delay: 2, view: view}) + frames := 4 + t.rng.Intn(4) + for i := 0; i < frames; i++ { + alt := i%2 == 0 + t.prints = append(t.prints, ctPrint{delay: 1, view: &CrazyTimeBonusView{Kind: ctBonusCoinFlip, Phase: "flip", CoinLow: low, CoinHigh: high, CoinFlipAlt: alt}}) + } + t.prints = append(t.prints, ctPrint{delay: 3, view: &CrazyTimeBonusView{Kind: ctBonusCoinFlip, Phase: "result", CoinLow: low, CoinHigh: high, CoinSide: side, CoinValue: value, CoinResult: true}, last: true}) +} + +func (t *crazyTimeTable) buildCashHuntLocked() { + board := make([]int, ctHuntSquares) + for i := range board { + board[i] = ctWeightedPick(t.rng, ctHuntValues, ctHuntWeights) + } + preview := append([]int(nil), board...) + t.rng.Shuffle(len(board), func(i, j int) { board[i], board[j] = board[j], board[i] }) + pick := t.rng.Intn(ctHuntSquares) + t.bonusValue = board[pick] + t.prints = append(t.prints, + ctPrint{delay: 3, view: &CrazyTimeBonusView{Kind: ctBonusCashHunt, Phase: "preview", HuntPreview: preview}}, + ctPrint{delay: 2, view: &CrazyTimeBonusView{Kind: ctBonusCashHunt, Phase: "hidden"}}, + ) + for i := 0; i < 2+t.rng.Intn(2); i++ { + t.prints = append(t.prints, ctPrint{delay: 1, view: &CrazyTimeBonusView{Kind: ctBonusCashHunt, Phase: "shuffle", HuntFinal: board}}) + } + t.prints = append(t.prints, ctPrint{delay: 3, view: &CrazyTimeBonusView{Kind: ctBonusCashHunt, Phase: "result", HuntFinal: board, HuntPick: pick}, last: true}) +} + +// crazyPachinkoPath builds a plausible drop path that ends on landing. Each +// row is one lane; the path eases from a random start toward the landing. +func crazyPachinkoPath(rng *rand.Rand, landing int) []int { + start := rng.Intn(ctPachinkoSlots) + path := make([]int, ctPachinkoSlots) + for row := 0; row < ctPachinkoSlots; row++ { + frac := float64(row) / float64(ctPachinkoSlots-1) + lane := int(float64(start)*(1-frac) + float64(landing)*frac + 0.5) + if row > 0 && row < ctPachinkoSlots-1 { + lane += rng.Intn(3) - 1 + } + if lane < 0 { + lane = 0 + } + if lane >= ctPachinkoSlots { + lane = ctPachinkoSlots - 1 + } + path[row] = lane + } + path[ctPachinkoSlots-1] = landing + return path +} + +// newPachinkoBoard deals fifteen fixed prizes plus one DOUBLE, then shuffles. +func (t *crazyTimeTable) newPachinkoBoardLocked() []int { + board := make([]int, ctPachinkoSlots) + for i := 0; i < ctPachinkoSlots-1; i++ { + board[i] = ctWeightedPick(t.rng, ctPachinkoValues, ctPachinkoWeights) + } + board[ctPachinkoSlots-1] = -1 // DOUBLE + t.rng.Shuffle(len(board), func(i, j int) { board[i], board[j] = board[j], board[i] }) + return board +} + +func (t *crazyTimeTable) buildPachinkoLocked() { + board := t.newPachinkoBoardLocked() + doubling := 0 + for drops := 0; drops < 20; drops++ { + topCopy := append([]int(nil), board...) + t.prints = append(t.prints, ctPrint{delay: 2, view: &CrazyTimeBonusView{Kind: ctBonusPachinko, Phase: "top", PachoPockets: topCopy, PachoTop: true, PachoDoubling: doubling}}) + landing := t.rng.Intn(ctPachinkoSlots) + path := crazyPachinkoPath(t.rng, landing) + for row := 1; row < ctPachinkoSlots; row++ { + t.prints = append(t.prints, ctPrint{delay: 1, view: &CrazyTimeBonusView{Kind: ctBonusPachinko, Phase: "drop", PachoPockets: board, PachoRow: row, PachoLane: path[row], PachoDoubling: doubling}}) + } + if board[landing] == -1 { + doubling++ + for i := range board { + if board[i] > 0 { + board[i] *= 2 + } + } + t.prints = append(t.prints, ctPrint{delay: 2, msg: "DOUBLE! Every prize is doubled and the puck drops again.", view: &CrazyTimeBonusView{Kind: ctBonusPachinko, Phase: "top", PachoPockets: append([]int(nil), board...), PachoTop: true, PachoDoubling: doubling}}) + continue + } + t.bonusValue = board[landing] + t.prints = append(t.prints, ctPrint{delay: 3, view: &CrazyTimeBonusView{Kind: ctBonusPachinko, Phase: "result", PachoPockets: board, PachoRow: ctPachinkoSlots, PachoLane: landing, PachoDoubling: doubling}, last: true}) + return + } + // Safety valve: an astronomically unlikely 20 doubles. Pay the current board. + t.bonusValue = 1 + for _, v := range board { + if v > 0 { + t.bonusValue = v + break + } + } + t.prints = append(t.prints, ctPrint{delay: 1, view: &CrazyTimeBonusView{Kind: ctBonusPachinko, Phase: "result", PachoPockets: board, PachoRow: ctPachinkoSlots, PachoLane: 0, PachoDoubling: doubling}, last: true}) +} + +// ctBonusWheelStops builds the 64-stop wheel: -1 marks a DOUBLE, otherwise +// the value is a fixed prize. The caller shuffles it for layout variety. +func ctBonusWheelStops() []int { + var wheel []int + for i := range ctBonusWheelValues { + for n := 0; n < ctBonusWheelCounts[i]; n++ { + wheel = append(wheel, ctBonusWheelValues[i]) + } + } + for i := 0; i < ctBonusDoubleCount; i++ { + wheel = append(wheel, -1) + } + return wheel +} + +// CrazyBonusPointerCells are the three pointer columns (as cell index within +// the rendered strip). The engine and renderer share them so the chosen +// pointer always shows the winning stop. +var CrazyBonusPointerCells = [3]int{2, 5, 8} + +func (t *crazyTimeTable) buildBonusWheelLocked() { + wheel := ctBonusWheelStops() + t.rng.Shuffle(len(wheel), func(i, j int) { wheel[i], wheel[j] = wheel[j], wheel[i] }) + pick := t.rng.Intn(3) + doubling := 0 + for spins := 0; spins < 20; spins++ { + winIndex := t.rng.Intn(ctBonusWheelSize) + value := wheel[winIndex] + pcol := CrazyBonusPointerCells[pick] + finalOffset := ((winIndex-pcol)%ctBonusWheelSize + ctBonusWheelSize) % ctBonusWheelSize + t.appendBonusSpinLocked(wheel, finalOffset, pick, doubling, value == -1) + if value == -1 { + doubling++ + for i := range wheel { + if wheel[i] > 0 { + wheel[i] *= 2 + } + } + continue + } + t.bonusValue = value + t.prints = append(t.prints, ctPrint{delay: 3, view: &CrazyTimeBonusView{Kind: ctBonusWheel, Phase: "result", CTWheel: append([]int(nil), wheel...), CTOffset: finalOffset, CTPick: pick, CTDoubling: doubling, CTResult: true, CTValue: value}, last: true}) + return + } + t.bonusValue = 1 + for _, v := range wheel { + if v > 0 { + t.bonusValue = v + break + } + } + t.prints = append(t.prints, ctPrint{delay: 1, view: &CrazyTimeBonusView{Kind: ctBonusWheel, Phase: "result", CTWheel: wheel, CTOffset: 0, CTPick: pick, CTDoubling: doubling, CTResult: true, CTValue: t.bonusValue}, last: true}) +} + +// appendBonusSpinLocked appends the frames of one Crazy Time wheel spin, +// ending on finalOffset. The spin is deliberately longer than the main +// wheel's. +func (t *crazyTimeTable) appendBonusSpinLocked(wheel []int, finalOffset, pick, doubling int, double bool) { + steps := make([]int, 0, 24) + step := 5 + t.rng.Intn(4) + for n := 4 + t.rng.Intn(3); n > 0; n-- { + steps = append(steps, step) + if step > 2 && t.rng.Intn(2) == 0 { + step-- + } + } + step = min(step, 2+t.rng.Intn(2)) + for n := 2 + t.rng.Intn(3); n > 0; n-- { + steps = append(steps, step) + } + for i := 0; i < 5+t.rng.Intn(4); i++ { + steps = append(steps, 1) + } + if t.rng.Intn(2) == 0 { + steps = append(steps, 0) + } + sum := 0 + for _, s := range steps { + sum += s + } + for sum < ctBonusWheelSize*3 { + steps = append([]int{8}, steps...) + sum += 8 + } + offset := ((finalOffset-sum)%ctBonusWheelSize + ctBonusWheelSize) % ctBonusWheelSize + for _, s := range steps { + t.prints = append(t.prints, ctPrint{delay: 1, view: &CrazyTimeBonusView{Kind: ctBonusWheel, Phase: "spin", CTWheel: append([]int(nil), wheel...), CTOffset: offset, CTPick: pick, CTDoubling: doubling}}) + offset = (offset + s) % ctBonusWheelSize + } + label := "SPIN" + if double { + label = "DOUBLE — respin!" + } + t.prints = append(t.prints, ctPrint{delay: 2, msg: label, view: &CrazyTimeBonusView{Kind: ctBonusWheel, Phase: "spin", CTWheel: append([]int(nil), wheel...), CTOffset: finalOffset, CTPick: pick, CTDoubling: doubling}}) +} + +// ----- Settlement ----- + +// settleLocked pays every committed wager from the fixed outcome. It is +// idempotent: a failed bank save keeps the stakes and the outcome, so settle +// retries the same payout. +func (t *crazyTimeTable) settleLocked() []Event { + if t.wallet == nil || len(t.bets) == 0 { + t.resetLocked() + return nil + } + mult := 1 + if t.winSymbol == t.topSymbol && t.topMult > 0 { + mult = t.topMult + } + winMult := crazyTimeWinMultiplier(t.winSymbol, t.bonusValue) + total := new(big.Int) + representable := true + var ids []string + var lines []SettlementLine + for _, b := range t.bets { + ids = append(ids, b.wager.ID) + returned := new(big.Int) + result := "loss" + odds := "0:1" + if b.spot == t.winSymbol { + returned.SetInt64(int64(b.wager.Total)) + returned.Mul(returned, big.NewInt(int64(winMult))) + returned.Mul(returned, big.NewInt(int64(mult))) + result = "win" + odds = fmt.Sprintf("%d:1", winMult*mult-1) + } + total.Add(total, returned) + if !returned.IsInt64() || returned.Cmp(big.NewInt(int64(int(^uint(0)>>1)))) > 0 { + representable = false + } + lines = append(lines, SettlementLine{ + Label: b.spot, + Odds: odds, + Stake: b.wager.Total, + Returned: int(returned.Int64()), + Result: result, + }) + } + failed := !representable || total.Cmp(big.NewInt(MaxCredits)) > 0 + if !failed { + failed = t.wallet.PayCredits(int(total.Int64()), ids...) != nil + } + e := t.playerAction(t.name, t.name+" settles their Crazy Time wagers.", "") + if failed { + t.phase = ctPending + e.PrivateMessage = payoutFailedMessage() + " Use settle to retry the same outcome." + e.Message = t.name + "'s settlement is pending recovery." + e.ShowBalance = true + return []Event{e} + } + e.PrivateMessage = settlementText(lines) + e.Settlement = lines + e.Color = settlementTag(lines) + events := []Event{e} + t.resetLocked() + events = append(events, t.menuEventLocked(t.name)) + return events +} + +func (t *crazyTimeTable) markDisconnected(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + var events []Event + switch { + case t.phase == ctPending: + // Leave the stakes pending for recovery; the seat clears. + case t.phase == ctSpinning || t.phase == ctBonusing: + if t.wallet != nil { + ids := make([]string, 0, len(t.bets)) + for _, b := range t.bets { + ids = append(ids, b.wager.ID) + } + if len(ids) > 0 { + t.wallet.ResolveStakesByIDs(ids...) + } + } + default: + if t.wallet != nil { + _, _, _ = t.wallet.RefundOutstanding() + } + } + t.clearLocked() + e := t.event(n+" leaves Crazy Time.", true) + e.Type, e.Player = EventLeft, n + return append(events, e) +} + +func (t *crazyTimeTable) refundAll() []Event { + t.mu.Lock() + defer t.mu.Unlock() + var events []Event + if t.phase == ctPending { + // A decided outcome whose payout could not be persisted: retry it so + // shutdown pays the same result rather than refunding a decided round. + events = append(events, t.settleLocked()...) + } else if (t.phase == ctSpinning || t.phase == ctBonusing) && t.wallet != nil { + chips, credits, err := t.wallet.RefundOutstanding() + message := fmt.Sprintf("Crazy Time closes; %d credits/chips refunded.", chips+credits) + if err != nil { + message = "Crazy Time refund could not be persisted; your stake remains pending for recovery." + } + events = append(events, t.private(t.name, message)) + } + t.clearLocked() + return events +} diff --git a/internal/casino/crazy_time_test.go b/internal/casino/crazy_time_test.go new file mode 100644 index 0000000..d783cc1 --- /dev/null +++ b/internal/casino/crazy_time_test.go @@ -0,0 +1,323 @@ +package casino + +import ( + "math" + "math/rand" + "strings" + "testing" +) + +func newCrazyTimeTest() *crazyTimeTable { + return newCrazyTimeTable(1, TableConfig{ID: "crazy", Game: GameCrazyTime, MinBet: 1, MaxBet: 100000}, rand.New(rand.NewSource(99))) +} + +func TestCrazyTimeRegistrationAndRTP(t *testing.T) { + cfg := TableConfig{ID: "crazy", Game: GameCrazyTime} + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + if cfg.Normalize().MaxPlayers != 1 { + t.Fatal("crazy time must be single-seat") + } + if cfg.HelpTopic() != "crazy time" { + t.Fatalf("help topic %q", cfg.HelpTopic()) + } + if newTable(1, cfg, newTestRand()) == nil { + t.Fatal("registration failed") + } + for _, spot := range crazyTimeSpots() { + rtp := crazyTimeRTPTargetFor(spot.Label) + if math.Abs(rtp-crazyTimeRTPTarget) > 1e-9 { + t.Fatalf("%s RTP = %.12f, want %.12f", spot.Label, rtp, crazyTimeRTPTarget) + } + e := cfg.HouseEdge(spot.Label) + if len(e.Parts) != 1 || math.Abs(e.Parts[0].Loss-(1-crazyTimeRTPTarget)) > 1e-9 { + t.Fatalf("%s edge %+v", spot.Label, e) + } + dist := crazyTimeTopDists[spot.Label] + if dist.total <= 0 { + t.Fatalf("%s empty top dist", spot.Label) + } + for _, w := range dist.weights { + if w < 0 { + t.Fatalf("%s negative weight", spot.Label) + } + } + mean := 0.0 + for i, w := range dist.weights { + mean += float64(dist.values[i]) * w + } + mean /= dist.total + if math.Abs(mean-crazyTimeTopTarget(spot.Label)) > 1e-9 { + t.Fatalf("%s top mean %.9f target %.9f", spot.Label, mean, crazyTimeTopTarget(spot.Label)) + } + } +} + +func TestCrazyTimeWheelCountsAndPositiveBonuses(t *testing.T) { + counts := map[string]int{} + for _, s := range crazyTimeWheel { + counts[s]++ + } + if len(crazyTimeWheel) != crazyTimePockets { + t.Fatalf("wheel has %d pockets", len(crazyTimeWheel)) + } + total := 0 + for _, spot := range crazyTimeSpots() { + if counts[spot.Label] != spot.Count { + t.Fatalf("%s count %d want %d", spot.Label, counts[spot.Label], spot.Count) + } + total += spot.Count + } + if total != crazyTimePockets { + t.Fatalf("counts sum to %d", total) + } + // Every bonus distribution is strictly positive. + for _, v := range ctHuntValues { + if v < 1 { + t.Fatalf("cash hunt value %d is not positive", v) + } + } + for _, v := range ctPachinkoValues { + if v < 1 { + t.Fatalf("pachinko value %d is not positive", v) + } + } + for _, v := range ctCoinLowValues { + if v < 1 { + t.Fatalf("coin low value %d is not positive", v) + } + } + for _, v := range ctCoinHighValues { + if v < 1 { + t.Fatalf("coin high value %d is not positive", v) + } + } + for _, v := range ctBonusWheelValues { + if v < 1 { + t.Fatalf("crazy wheel value %d is not positive", v) + } + } +} + +func TestCrazyTimeBetSpinSettle(t *testing.T) { + table := newCrazyTimeTest() + w := &recordingWallet{testWallet: testWallet{credits: 1000}} + table.join("a") + table.bet("a", "1", 10, w) + table.bet("a", "crazy time", 5, w) + if len(table.bets) != 2 || len(w.open) != 2 { + t.Fatalf("bets not committed: %+v open=%d", table.bets, len(w.open)) + } + // Capture the fixed outcome before the wheel settles and resets it. + table.topSymbol, table.topMult = "2", 7 + events := table.action("a", "spin") + if table.phase != ctSpinning || len(events) == 0 { + t.Fatal("spin did not start") + } + win := table.winSymbol + topSymbol, topMult := table.topSymbol, table.topMult + if win == "" { + t.Fatal("no winning symbol") + } + // Drive the wheel to completion. + for i := 0; i < 400 && table.phase == ctSpinning; i++ { + table.tick() + } + // A bonus may still be animating; drive it out too. + for i := 0; i < 1000 && table.phase == ctBonusing; i++ { + table.tick() + } + mult := 1 + if topSymbol == win { + mult = topMult + } + want := 0 + if win == "1" { + want += 10 * 2 * mult + } + if win == "CRAZY TIME" { + want += 5 * table.bonusValue * mult + } + if w.paid != want { + t.Fatalf("paid %d want %d (win=%s top=%s x%d)", w.paid, want, win, topSymbol, topMult) + } + if len(w.open) != 0 { + t.Fatalf("stakes left open: %d", len(w.open)) + } +} + +func TestCrazyTimeAllBonusesOnlyPayPositive(t *testing.T) { + for _, kind := range []string{ctBonusCoinFlip, ctBonusCashHunt, ctBonusPachinko, ctBonusWheel} { + for seed := int64(0); seed < 40; seed++ { + table := newCrazyTimeTable(1, TableConfig{ID: "c", Game: GameCrazyTime, MinBet: 1, MaxBet: 100000}, rand.New(rand.NewSource(seed+1))) + table.name = "a" + table.phase = ctBonusing + table.winSymbol = labelForBonus(kind) + table.topSymbol, table.topMult = "10", 1 + switch kind { + case ctBonusCoinFlip: + table.buildCoinFlipLocked() + case ctBonusCashHunt: + table.buildCashHuntLocked() + case ctBonusPachinko: + table.buildPachinkoLocked() + case ctBonusWheel: + table.buildBonusWheelLocked() + } + if table.bonusValue < 1 { + t.Fatalf("%s produced %d", kind, table.bonusValue) + } + if len(table.prints) == 0 { + t.Fatalf("%s produced no frames", kind) + } + } + } +} + +func labelForBonus(kind string) string { + for _, s := range crazyTimeSpots() { + if s.Bonus == kind { + return s.Label + } + } + return "" +} + +func TestCrazyTimeUnbetRefunds(t *testing.T) { + table := newCrazyTimeTest() + w := &testWallet{credits: 100} + table.join("a") + table.bet("a", "5", 10, w) + if w.credits != 90 { + t.Fatalf("stake not debited: %d", w.credits) + } + table.action("a", "unbet") + if w.paid != 10 || len(w.open) != 0 { + t.Fatalf("unbet did not refund: paid=%d open=%d", w.paid, len(w.open)) + } +} + +func TestCrazyTimeAllBetAndRebet(t *testing.T) { + table := newCrazyTimeTest() + w := &testWallet{credits: 1000} + table.join("a") + + events := table.bet("a", "all", 10, w) + if len(table.bets) != 4 { + t.Fatalf("ALL placed %d wagers, want 4", len(table.bets)) + } + if w.credits != 960 || len(w.open) != 4 { + t.Fatalf("ALL debit: credits=%d open=%d", w.credits, len(w.open)) + } + for _, e := range events { + if e.Menu != nil { + t.Fatalf("bet returned a menu (wallet would print): %+v", e) + } + } + + // Settle on a losing number, then rebet every remembered minigame wager. + table.winSymbol = "10" + table.topSymbol, table.topMult = "1", 1 + table.settleLocked() + if len(table.bets) != 0 { + t.Fatal("settlement did not clear the round") + } + before := w.credits + events = table.rebetAll("a", w) + if len(table.bets) != 4 { + t.Fatalf("rebet placed %d wagers, want 4", len(table.bets)) + } + if w.credits != before-40 { + t.Fatalf("rebet debit: credits=%d want %d", w.credits, before-40) + } + for _, e := range events { + if e.Menu != nil { + t.Fatalf("rebet returned a menu: %+v", e) + } + } +} + +func TestCrazyTimeResolveSpot(t *testing.T) { + cases := map[string]string{ + "1": "1", "2": "2", "5": "5", "10": "10", + "co": "COIN FLIP", "coin flip": "COIN FLIP", "coinflip": "COIN FLIP", + "ca": "CASH HUNT", "cash hunt": "CASH HUNT", + "p": "PACHINKO", "pach": "PACHINKO", + "cr": "CRAZY TIME", "crazy time": "CRAZY TIME", + } + for in, want := range cases { + got, ok := crazyTimeResolveSpot(in) + if !ok || got != want { + t.Fatalf("%q -> %q,%v want %q", in, got, ok, want) + } + } + if _, ok := crazyTimeResolveSpot("c"); ok { + t.Fatal("ambiguous c should not resolve") + } +} + +func TestCrazyTimeRulesText(t *testing.T) { + rules := crazyTimeRules(TableConfig{}) + if !strings.Contains(rules, "98%") || strings.Contains(rules, "%%") { + t.Fatalf("rules text: %q", rules) + } + if !strings.Contains(rules, "CASH HUNT") || !strings.Contains(rules, "Top Slot") { + t.Fatalf("rules text missing content: %q", rules) + } +} + +// TestCrazyTimeSettlementMath pins the payout arithmetic: a winning target +// pays stake * total-return-multiplier * Top Slot multiplier, and a losing +// target returns nothing. +func TestCrazyTimeSettlementMath(t *testing.T) { + cases := []struct { + spot, win, topSym string + bonus, topMult int + stake, want int + }{ + {"1", "1", "1", 0, 5, 10, 10 * 2 * 5}, + {"1", "10", "1", 0, 5, 10, 0}, + {"CASH HUNT", "CASH HUNT", "CASH HUNT", 3, 4, 7, 7 * 3 * 4}, + {"CASH HUNT", "CASH HUNT", "COIN FLIP", 500, 9, 2, 2 * 500}, + {"CRAZY TIME", "CRAZY TIME", "CRAZY TIME", 64, 50, 1, 64 * 50}, + } + for _, tc := range cases { + table := newCrazyTimeTable(1, TableConfig{ID: "c", Game: GameCrazyTime, MinBet: 1, MaxBet: 100000}, rand.New(rand.NewSource(3))) + w := &testWallet{credits: MaxCredits / 2} + table.name = "a" + table.wallet = w + wager, err := w.Wager(tc.stake) + if err != nil { + t.Fatal(err) + } + table.bets = []crazyTimeBet{{spot: tc.spot, wager: wager}} + table.winSymbol = tc.win + table.bonusValue = tc.bonus + table.topSymbol, table.topMult = tc.topSym, tc.topMult + before := w.paid + table.settleLocked() + if got := w.paid - before; got != tc.want { + t.Fatalf("%s win=%s bonus=%d top=%s x%d stake=%d paid %d want %d", tc.spot, tc.win, tc.bonus, tc.topSym, tc.topMult, tc.stake, got, tc.want) + } + } +} + +func TestCrazyTimeSpinDelaysFirstMove(t *testing.T) { + table := newCrazyTimeTest() + w := &testWallet{credits: 1000} + table.join("a") + table.bet("a", "1", 10, w) + events := table.action("a", "spin") + if len(events) == 0 || events[0].Snapshot == nil { + t.Fatal("spin did not print the opening wheel") + } + // The spin command and the first engine tick share a cycle, so the opening + // position must survive that tick before the wheel advances. + if ev := table.tick(); len(ev) != 0 { + t.Fatalf("first tick advanced the wheel immediately: %+v", ev) + } + if ev := table.tick(); len(ev) == 0 { + t.Fatal("second tick did not advance the wheel") + } +} diff --git a/internal/casino/pai_gow.go b/internal/casino/pai_gow.go index 8a7ddf3..ad0693d 100644 --- a/internal/casino/pai_gow.go +++ b/internal/casino/pai_gow.go @@ -417,9 +417,9 @@ func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event { // The main wager is the commitment point: when every active player has // one, the cards come out immediately rather than waiting on the timer. if spot == "main" && t.allBetLocked() { - return append(events, t.dealLocked()...) + return stripMenuBalance(append(events, t.dealLocked()...)) } - return append(events, t.statusLocked(name, false)...) + return stripMenuBalance(append(events, t.statusLocked(name, false)...)) } func (t *paiGowTable) allReady() bool { found := false diff --git a/internal/casino/registry.go b/internal/casino/registry.go index e87b45a..9d6017d 100644 --- a/internal/casino/registry.go +++ b/internal/casino/registry.go @@ -135,6 +135,15 @@ func init() { ruleText: bigSixRules, helpTopic: func(TableConfig) string { return "big6" }, }, + GameCrazyTime: { + houseEdge: crazyTimeHouseEdge, + validate: validateCrazyTime, + normalize: func(c TableConfig) TableConfig { c.MaxPlayers = 1; return c }, + hasRuleBlock: func(TableConfig) bool { return false }, + new: func(room int, c TableConfig, rng *rand.Rand) tableGame { return newCrazyTimeTable(room, c, rng) }, + ruleText: crazyTimeRules, + helpTopic: func(TableConfig) string { return "crazy time" }, + }, GameRoulette: { houseEdge: rouletteHouseEdge, validate: func(c TableConfig) error { diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go index 71a0916..254a472 100644 --- a/internal/casino/roulette.go +++ b/internal/casino/roulette.go @@ -459,8 +459,15 @@ func rouletteBasket(nums []string, double bool) ([]string, int, bool) { return nil, 0, false } +type roulettePlacedBet struct { + spot string + wager Wager +} + type roulettePlayer struct { Participant + bets []roulettePlacedBet + lastBets []rebetWager lastSpot string lastBet int } @@ -519,8 +526,8 @@ func (t *rouletteTable) menuEvent(name string, board bool) Event { p := t.players.get(name) if t.phase != phasePlaying && p != nil && !p.HasBet { commands = append(commands, MenuCommand{Command: "bet N on BET", Desc: "Place one wager"}) - if p.lastBet > 0 { - commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat your previous wager"}) + if len(p.lastBets) > 0 { + commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat all your last wagers"}) } } if p != nil { @@ -564,15 +571,20 @@ func (t *rouletteTable) leave(name string) []Event { func (t *rouletteTable) bet(name, spot string, amount int, w Wallet) []Event { t.mu.Lock() defer t.mu.Unlock() + events, _ := t.betLocked(name, spot, amount, w) + return events +} + +// betLocked is the locked implementation of bet, shared with the multi-wager +// replay so a rebet holds the table lock across every stake. It reports false +// when no wager was placed. +func (t *rouletteTable) betLocked(name, spot string, amount int, w Wallet) ([]Event, bool) { p := t.players.get(name) if p == nil { - return []Event{t.private(name, "You aren't playing roulette.")} + return []Event{t.private(name, "You aren't playing roulette.")}, false } if t.phase == phasePlaying { - return []Event{t.private(name, "No more bets! The wheel is spinning.")} - } - if p.HasBet { - return []Event{t.private(name, "You have already bet this spin.")} + return []Event{t.private(name, "No more bets! The wheel is spinning.")}, false } if spot == "" { spot = p.lastSpot @@ -580,21 +592,24 @@ func (t *rouletteTable) bet(name, spot string, amount int, w Wallet) []Event { spot = normalizeRouletteSpot(spot) resolved, ok := rouletteWagerFor(spot, t.Config.Roulette.DoubleZero, t.Config.Roulette.Racetrack) if !ok { - return []Event{t.private(name, "Unknown roulette target. Use table to see all available bets.")} + return []Event{t.private(name, "Unknown roulette target. Use table to see all available bets.")}, false } if amount < t.Config.MinBet || amount > t.Config.MaxBet { - return []Event{t.private(name, "Roulette bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")} + return []Event{t.private(name, "Roulette bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")}, false } // Check every possible winning return before committing even an unlimited // wager: a call bet's components pay different multiples. if err := resolved.validate(amount); err != nil { - return []Event{t.private(name, "That wager's potential payout is too large.")} + return []Event{t.private(name, "That wager's potential payout is too large.")}, false } wager, err := w.Wager(amount) if err != nil { - return []Event{t.private(name, walletErrorMessage(err))} + return []Event{t.private(name, walletErrorMessage(err))}, false } - p.HasBet, p.Wager, p.Wallet, p.lastSpot, p.lastBet, p.SittingOut = true, wager, w, spot, amount, false + p.bets = append(p.bets, roulettePlacedBet{spot: spot, wager: wager}) + p.HasBet, p.Wallet, p.lastSpot, p.lastBet, p.SittingOut = true, w, spot, amount, false + p.Wager.Total += amount + p.Wager.ID = wager.ID events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d on %s.", name, amount, spot), fmt.Sprintf("You bet %d on %s.", amount, spot))} // With two or more players a timed betting window lets the table bet // together. A lone player has no time limit: once their wager lands, @@ -611,7 +626,7 @@ func (t *rouletteTable) bet(name, spot string, amount int, w Wallet) []Event { events = append(events, t.startSpin()) } } - return events + return events, true } func (t *rouletteTable) tick() []Event { t.mu.Lock() @@ -729,23 +744,39 @@ func (t *rouletteTable) settle() []Event { if !p.HasBet { continue } - wager, resolved := rouletteWagerFor(p.lastSpot, t.Config.Roulette.DoubleZero, t.Config.Roulette.Racetrack) - payout := 0 + totalPayout := 0 result := "loss" + var ids []string + var lines []SettlementLine var err error - if resolved { - if share, ok := wager.covers(pocket); ok { + for _, b := range p.bets { + ids = append(ids, b.wager.ID) + wager, resolved := rouletteWagerFor(b.spot, t.Config.Roulette.DoubleZero, t.Config.Roulette.Racetrack) + payout := 0 + res := "loss" + if resolved { + if share, ok := wager.covers(pocket); ok { + res = "win" + payout, err = share.payout(b.wager.Total) + } + } + if err != nil { + break + } + totalPayout += payout + if res == "win" { result = "win" - payout, err = share.payout(p.Wager.Total) } + lines = append(lines, SettlementLine{Label: b.spot, Odds: rouletteSettlementOdds(wager, pocket), Stake: b.wager.Total, Returned: payout, Result: res}) } - // PayCredits supports a zero return and atomically consumes a losing - // stake too, surfacing persistence errors instead of announcing success. + // PayCredits supports a zero return and atomically consumes every + // losing stake too, surfacing persistence errors instead of + // announcing success. failed := err != nil if !failed { - failed = p.Wallet.PayCredits(payout, p.Wager.ID) != nil + failed = p.Wallet.PayCredits(totalPayout, ids...) != nil } - text := resultTextLabel(p.lastSpot, result, p.Wager.Total, payout) + text := settlementText(lines) public := p.Name + " " + result + "." if failed { text = payoutFailedMessage() @@ -756,7 +787,7 @@ func (t *rouletteTable) settle() []Event { // departures/sit-outs need a balance attached to the settlement. event.ShowBalance = p.SettlementBalanceNeeded(failed) if !failed { - event.Settlement = []SettlementLine{{Label: p.lastSpot, Odds: rouletteSettlementOdds(wager, pocket), Stake: p.Wager.Total, Returned: payout, Result: result}} + event.Settlement = lines if result == "win" { event.Color = "casino_result_win" } else { @@ -778,6 +809,13 @@ func (t *rouletteTable) resetRound() { t.phase = phaseWaiting t.window.close() t.spinRemaining = 0 + for _, p := range t.players.ordered() { + p.lastBets = p.lastBets[:0] + for _, b := range p.bets { + p.lastBets = append(p.lastBets, rebetWager{spot: b.spot, amount: b.wager.Total}) + } + p.bets = nil + } clearBets(t.players.participants()) } func (t *rouletteTable) action(name, action string) []Event { @@ -795,6 +833,38 @@ func (t *rouletteTable) action(name, action string) []Event { } return []Event{t.private(name, "Roulette has no player actions after betting.")} } + +// unbetLocked returns every stake the seat committed this round. Roulette +// allows several wagers in one spin, so it shadows the shared single-stake +// helper. +func (t *rouletteTable) unbetLocked(name string) ([]Event, bool) { + p := t.players.get(name) + if p == nil { + return []Event{t.private(name, "You aren't seated at this table.")}, true + } + if t.phase != phaseWaiting && t.phase != phaseBetting { + return []Event{t.private(name, "You can only take back bets before the round starts.")}, true + } + if !p.HasBet || len(p.bets) == 0 { + return []Event{t.private(name, "Nothing to take back.")}, true + } + total := 0 + var ids []string + for _, b := range p.bets { + total += b.wager.Total + if b.wager.ID != "" { + ids = append(ids, b.wager.ID) + } + } + if p.Wallet != nil { + if err := p.Wallet.RefundStakes(ids...); err != nil { + return []Event{t.private(name, payoutFailedMessage())}, true + } + } + p.HasBet, p.Wager, p.bets = false, Wager{}, nil + return []Event{t.private(name, fmt.Sprintf("You take back %d credits.", total))}, true +} + func (t *rouletteTable) rebet(name string) (string, int) { t.mu.Lock() defer t.mu.Unlock() @@ -803,6 +873,32 @@ func (t *rouletteTable) rebet(name string) (string, int) { } return "", t.Config.MinBet } + +// rebetAll repeats every remembered wager from the previous spin in one +// command, so a bare bet/rebet restores a whole spread. +func (t *rouletteTable) rebetAll(name string, w Wallet) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.players.get(name) + if p == nil { + return []Event{t.private(name, "You aren't playing roulette.")} + } + if t.phase == phasePlaying { + return []Event{t.private(name, "No more bets! The wheel is spinning.")} + } + if len(p.lastBets) == 0 { + return []Event{t.private(name, "Bet on a number, color, or combination. Use table to see all available bets.")} + } + var events []Event + for _, b := range p.lastBets { + ev, ok := t.betLocked(name, b.spot, b.amount, w) + events = append(events, ev...) + if !ok { + break + } + } + return events +} func (t *rouletteTable) status(name string) []Event { t.mu.Lock() defer t.mu.Unlock() @@ -830,7 +926,11 @@ func (t *rouletteTable) markDisconnected(name string) []Event { if p.Wallet != nil { var err error if p.HasBet && t.phase == phasePlaying { - err = p.Wallet.PayCredits(0, p.Wager.ID) + var ids []string + for _, b := range p.bets { + ids = append(ids, b.wager.ID) + } + err = p.Wallet.PayCredits(0, ids...) } else { _, _, err = p.Wallet.RefundOutstanding() } diff --git a/internal/casino/roulette_test.go b/internal/casino/roulette_test.go index a0e04d4..3425f94 100644 --- a/internal/casino/roulette_test.go +++ b/internal/casino/roulette_test.go @@ -281,9 +281,15 @@ func TestRouletteTimedPhasesAndSettlement(t *testing.T) { w := &testWallet{chips: 100} table.bet("a", wheel[index], 10, w) table.bet("a", "red", 10, w) - if w.chips != 90 || table.phase != phaseBetting { + if w.chips != 80 || table.phase != phaseBetting { t.Fatal("duplicate or early spin") } + // Both wagers are on the table: the straight-up wins its 36x total + // return, and the red wager adds 20 when the winning pocket is red. + wantPaid := 360 + if n, err := strconv.Atoi(wheel[index]); err == nil && rouletteRed(n) { + wantPaid += 20 + } table.tick() if table.phase != phaseBetting { t.Fatal("betting ended early") @@ -304,7 +310,7 @@ func TestRouletteTimedPhasesAndSettlement(t *testing.T) { } table.action("a", "leave_after") events = table.tick() - if w.paid != 360 || len(w.open) != 0 || table.hasParticipant("a") { + if w.paid != wantPaid || len(w.open) != 0 || table.hasParticipant("a") { t.Fatalf("bad settlement: %+v", w) } s := events[0].Snapshot.(*RouletteSnapshot) @@ -318,7 +324,7 @@ func TestRouletteTimedPhasesAndSettlement(t *testing.T) { } table.tick() table.refundAll() - if w.paid != 360 { + if w.paid != wantPaid { t.Fatal("double paid") } } diff --git a/internal/casino/table.go b/internal/casino/table.go index 4ab4e3a..f0d5b3d 100644 --- a/internal/casino/table.go +++ b/internal/casino/table.go @@ -108,10 +108,13 @@ func (t *Table) action(name, action string) []Event { return t.game.action(name, func (t *Table) rebet(name string) (string, int) { return t.game.rebet(name) } // RebetAll repeats every remembered wager for a game that keeps more than one -// (currently baccarat's side bets plus main bet). It returns nil for games -// whose bare "bet" is a single-wager repeat. +// (a spread of roulette, Big 6, Crazy Time or baccarat wagers). It returns nil +// for games whose bare "bet" is a single-wager repeat, so the caller falls +// back to the singular rebet path. func (t *Table) RebetAll(name string, wallet Wallet) []Event { - if b, ok := t.game.(*baccaratTable); ok { + if b, ok := t.game.(interface { + rebetAll(string, Wallet) []Event + }); ok { return b.rebetAll(name, wallet) } return nil diff --git a/internal/casino/table_hints.go b/internal/casino/table_hints.go index 3a87c94..ea6aef1 100644 --- a/internal/casino/table_hints.go +++ b/internal/casino/table_hints.go @@ -20,8 +20,8 @@ func (t *rouletteTable) hints(name string) []MenuCommand { commands := []MenuCommand{} if t.phase != phasePlaying && !p.HasBet { commands = append(commands, MenuCommand{Command: "bet N on BET", Desc: "Place one wager"}) - if p.lastBet > 0 { - commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat your previous wager"}) + if len(p.lastBets) > 0 { + commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat all your last wagers"}) } } commands = append(commands, MenuCommand{Command: "stop", Desc: "Leave when your wager settles"}) diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go index d23c8a2..5613a81 100644 --- a/internal/casino/table_ux.go +++ b/internal/casino/table_ux.go @@ -14,6 +14,13 @@ type WagerRow struct { Win string } +// rebetWager is one remembered wager a multi-bet game can replay on a bare +// bet/rebet. +type rebetWager struct { + spot string + amount int +} + const ( hintCommandRules = "rules" hintCommandList = "list" @@ -36,6 +43,18 @@ func withTableHints(commands []MenuCommand, wagerRows []WagerRow) []MenuCommand return commands } +// stripMenuBalance clears the wallet display from a bet response's menus, so +// placing a wager never prints the balance. Explicit status/look and later +// settlements keep their balance. +func stripMenuBalance(events []Event) []Event { + for i := range events { + if events[i].Menu != nil { + events[i].Menu.ShowBalance = false + } + } + return events +} + // RulesText is the shared rules view for table commands and room fixtures. func (c TableConfig) RulesText() string { return rulesTextOnTable(c) } diff --git a/internal/casino/types.go b/internal/casino/types.go index 411b58d..33b5386 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -25,6 +25,7 @@ const ( GameBaccarat GameName = "baccarat" GameRoulette GameName = "roulette" GameBigSix GameName = "big6" + GameCrazyTime GameName = "crazy_time" GameCraps GameName = "craps" ) diff --git a/internal/casino/video_poker.go b/internal/casino/video_poker.go index 98bfda1..10ef03a 100644 --- a/internal/casino/video_poker.go +++ b/internal/casino/video_poker.go @@ -343,7 +343,7 @@ func (t *videoPokerTable) bet(name, spot string, amount int, w Wallet) []Event { } p.remaining = append([]card(nil), deck.cards...) p.active, p.drawn, p.held, p.result = true, false, [5]bool{}, "" - return t.statusLocked(name, false) + return stripMenuBalance(t.statusLocked(name, false)) } func (t *videoPokerTable) rebet(name string) (string, int) { return "", 0 // Coin loading is session state handled by the cabinet's own commands. -- cgit v1.2.3